├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README-zh_CN.md ├── README.md ├── bin └── skeleton.js ├── demo └── index.js ├── package.json ├── rollup.config.js ├── src ├── index.js ├── insertSkeleton.js ├── openPage.js ├── saveFile.js ├── script │ ├── constants.js │ ├── dist │ │ └── index.js │ ├── handler │ │ ├── a.js │ │ ├── before.js │ │ ├── button.js │ │ ├── empty.js │ │ ├── img.js │ │ ├── index.js │ │ ├── input.js │ │ ├── list.js │ │ ├── pseudo.js │ │ ├── script.js │ │ ├── style.js │ │ ├── svg.js │ │ └── text.js │ ├── main.js │ └── util.js └── util.js └── test ├── lib └── index.test.js └── mocha.opts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | demo 2 | dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-egg", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "experimentalObjectRestSpread": true 7 | } 8 | }, 9 | "globals": { 10 | "window": true, 11 | "document": true, 12 | "getComputedStyle": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/vim,node,code,webstorm,sublimetext 2 | 3 | ### Project ### 4 | demo 5 | 6 | ### Code ### 7 | # Visual Studio Code - https://code.visualstudio.com/ 8 | .settings/ 9 | .vscode/ 10 | tsconfig.json 11 | jsconfig.json 12 | 13 | ### Node ### 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # TypeScript v1 declaration files 53 | typings/ 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | 73 | # parcel-bundler cache (https://parceljs.org/) 74 | .cache 75 | 76 | # next.js build output 77 | .next 78 | 79 | # nuxt.js build output 80 | .nuxt 81 | 82 | # vuepress build output 83 | .vuepress/dist 84 | 85 | # Serverless directories 86 | .serverless 87 | 88 | ### SublimeText ### 89 | # Cache files for Sublime Text 90 | *.tmlanguage.cache 91 | *.tmPreferences.cache 92 | *.stTheme.cache 93 | 94 | # Workspace files are user-specific 95 | *.sublime-workspace 96 | 97 | # Project files should be checked into the repository, unless a significant 98 | # proportion of contributors will probably not be using Sublime Text 99 | # *.sublime-project 100 | 101 | # SFTP configuration file 102 | sftp-config.json 103 | 104 | # Package control specific files 105 | Package Control.last-run 106 | Package Control.ca-list 107 | Package Control.ca-bundle 108 | Package Control.system-ca-bundle 109 | Package Control.cache/ 110 | Package Control.ca-certs/ 111 | Package Control.merged-ca-bundle 112 | Package Control.user-ca-bundle 113 | oscrypto-ca-bundle.crt 114 | bh_unicode_properties.cache 115 | 116 | # Sublime-github package stores a github token in this file 117 | # https://packagecontrol.io/packages/sublime-github 118 | GitHub.sublime-settings 119 | 120 | ### Vim ### 121 | # Swap 122 | [._]*.s[a-v][a-z] 123 | [._]*.sw[a-p] 124 | [._]s[a-rt-v][a-z] 125 | [._]ss[a-gi-z] 126 | [._]sw[a-p] 127 | 128 | # Session 129 | Session.vim 130 | 131 | # Temporary 132 | .netrwhist 133 | *~ 134 | # Auto-generated tag files 135 | tags 136 | # Persistent undo 137 | [._]*.un~ 138 | 139 | ### WebStorm ### 140 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 141 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 142 | 143 | # User-specific stuff 144 | .idea 145 | .idea/**/workspace.xml 146 | .idea/**/tasks.xml 147 | .idea/**/usage.statistics.xml 148 | .idea/**/dictionaries 149 | .idea/**/shelf 150 | 151 | # Generated files 152 | .idea/**/contentModel.xml 153 | 154 | # Sensitive or high-churn files 155 | .idea/**/dataSources/ 156 | .idea/**/dataSources.ids 157 | .idea/**/dataSources.local.xml 158 | .idea/**/sqlDataSources.xml 159 | .idea/**/dynamic.xml 160 | .idea/**/uiDesigner.xml 161 | .idea/**/dbnavigator.xml 162 | 163 | # Gradle 164 | .idea/**/gradle.xml 165 | .idea/**/libraries 166 | 167 | # Gradle and Maven with auto-import 168 | # When using Gradle or Maven with auto-import, you should exclude module files, 169 | # since they will be recreated, and may cause churn. Uncomment if using 170 | # auto-import. 171 | # .idea/modules.xml 172 | # .idea/*.iml 173 | # .idea/modules 174 | 175 | # CMake 176 | cmake-build-*/ 177 | 178 | # Mongo Explorer plugin 179 | .idea/**/mongoSettings.xml 180 | 181 | # File-based project format 182 | *.iws 183 | 184 | # IntelliJ 185 | out/ 186 | 187 | # mpeltonen/sbt-idea plugin 188 | .idea_modules/ 189 | 190 | # JIRA plugin 191 | atlassian-ide-plugin.xml 192 | 193 | # Cursive Clojure plugin 194 | .idea/replstate.xml 195 | 196 | # Crashlytics plugin (for Android Studio and IntelliJ) 197 | com_crashlytics_export_strings.xml 198 | crashlytics.properties 199 | crashlytics-build.properties 200 | fabric.properties 201 | 202 | # Editor-based Rest Client 203 | .idea/httpRequests 204 | 205 | # Android studio 3.1+ serialized cache file 206 | .idea/caches/build_file_checksums.ser 207 | 208 | ### WebStorm Patch ### 209 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 210 | 211 | # *.iml 212 | # modules.xml 213 | # .idea/misc.xml 214 | # *.ipr 215 | 216 | # Sonarlint plugin 217 | .idea/sonarlint 218 | 219 | 220 | # End of https://www.gitignore.io/api/vim,node,code,webstorm,sublimetext 221 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | test/ 3 | coverage/ 4 | docs/ 5 | docs_dist/ 6 | logs/ 7 | demo/ 8 | *.md 9 | *.tgz 10 | *.yml 11 | *.sw* 12 | *.un~ 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '8' 5 | script: 6 | - npm run ci 7 | after_success: 8 | - npm i codecov && codecov 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.1.3](https://github.com/kaola-fed/awesome-skeleton/compare/v0.1.2...v0.1.3) (2019-09-23) 3 | 4 | 5 | ### Features 6 | 7 | * modify onload destory time ([ecb8b32](https://github.com/kaola-fed/awesome-skeleton/commit/ecb8b32)) 8 | 9 | 10 | 11 | 12 | ## [0.1.2](https://github.com/kaola-fed/awesome-skeleton/compare/v0.1.1...v0.1.2) (2019-09-19) 13 | 14 | 15 | ### Features 16 | 17 | * remove delayTime param; handle dl dd dt list item ([7a827ca](https://github.com/kaola-fed/awesome-skeleton/commit/7a827ca)) 18 | * add ci ([5b64be6](https://github.com/kaola-fed/awesome-skeleton/commit/5b64be6)) 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 zivyangll 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_CN.md: -------------------------------------------------------------------------------- 1 | # awesome-skeleton 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Build status][travis-image]][travis-url] 5 | [![Dependency status][daviddm-image]][daviddm-url] 6 | 7 | > 骨架屏生成工具 8 | 9 | [English](./README.md) | 简体中文 10 | 11 | 12 | 13 | 14 | ## Contributors 15 | 16 | |[
zivyangll](https://github.com/zivyangll)
| 17 | | :---: | 18 | 19 | 20 | 21 | ## 效果 22 | 23 | 查看线上效果:[考拉购物车](https://m-buy.kaola.com/cart.html) 24 | 25 | ![kaola skeleton](https://user-images.githubusercontent.com/11460601/65293821-19225f00-db8f-11e9-802f-ef34458e9c58.jpg) 26 | 27 | ## 说明 28 | * 骨架图生成组件,仅限node端使用。该组件提供骨架图生成和骨架图模板注入两个能力。 29 | * 骨架图生成逻辑:通过传入页面地址,使用 Puppeteer 无头浏览器打开页面地址,对页面首屏图片和文本等节点进行灰色背景处理,然后对页面首屏进行截图,生成压缩后的 base64 png 图片。 30 | 31 | ## 安装 32 | 33 | ### 全局安装 34 | 35 | ```bash 36 | $ npm i awesome-skeleton -g 37 | ``` 38 | 39 | ### 项目中安装 40 | ```bash 41 | $ npm i awesome-skeleton -D 42 | ``` 43 | 44 | ## 使用方法 45 | 46 | ### 添加配置文件 47 | 48 | skeleton.config.json: 49 | 50 | ```json 51 | { 52 | "pageName": "baidu", 53 | "pageUrl": "https://www.baidu.com", 54 | "openRepeatList": false, 55 | "device": "iPhone X", 56 | "minGrayBlockWidth": 80, 57 | "minGrayPseudoWidth": 10, 58 | "debug": true, 59 | "debugTime": 3000, 60 | "cookies": [ 61 | { 62 | "domain": ".baidu.com", 63 | "expirationDate": 1568267131.555328, 64 | "hostOnly": false, 65 | "httpOnly": false, 66 | "name": "BDORZ", 67 | "path": "/", 68 | "sameSite": "unspecified", 69 | "secure": false, 70 | "session": false, 71 | "storeId": "0", 72 | "value": "yyyyyyyyy", 73 | "id": 2 74 | } 75 | ] 76 | } 77 | ``` 78 | 79 | ### 全局生成骨架屏 80 | 81 | ```bash 82 | $ skeleton -c ./skeleton.config.json 83 | ``` 84 | 85 | 页面 DomReady 之后,会在页面顶部出现红色按钮:开始生成骨架屏。 86 | 87 | 生成完成后,会在运行目录生成 skeleton-output 文件件,里面包括骨架屏 png 图片、base64 文本、html 文件: 88 | - base64-baidu.png # 骨架图图片 89 | - base64-baidu.txt # 骨架图 Base64 编码 90 | - base64-baidu.html # 最终生成 HTML 91 | 92 | 其中 html 文件可以直接拿来用,复制下面位置: 93 | 94 | ```html 95 | 96 | 97 | 98 | 99 | 100 | ``` 101 | 102 | 注意: 103 | - 骨架图默认在 onload 事件后销毁。 104 | - 手动销毁方式: 105 | 106 | ```js 107 | window.SKELETON && SKELETON.destroy(); 108 | ``` 109 | 110 | **当然,你也可以在项目中直接使用生成的 Base64 图片** 111 | 112 | ### 项目中生成骨架屏 113 | 114 | 在 package.json 中添加脚本: 115 | 116 | ```json 117 | "scripts": { 118 | "skeleton": "skeleton -c ./skeleton.config.json" 119 | } 120 | ``` 121 | 122 | 生成骨架屏: 123 | 124 | ```bash 125 | $ npm run skeleton 126 | ``` 127 | 128 | ### 解决登录态 129 | 130 | 如果页面需要登录,则需要下载 Chrome 插件 [EditThisCookie](https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg),将 Cookie 复制到配置参数中。 131 | 132 | ## 参数 133 | 134 | | 参数名称 | 必填 | 默认值 | 说明 | 135 | | --- | --- | --- | --- | 136 | | pageUrl | 是 | - | 页面地址(此地址必须可访问) | 137 | | pageName | 否 | output | 页面名称(仅限英文) | 138 | | cookies | 否 | | 页面 Cookies,用来解决登录态问题 | 139 | | outputPath | 否 | skeleton-output | 骨架图文件输出文件夹路径,默认到项目 skeleton-output 中 | 140 | | openRepeatList | 否 | true | 默认会将每个列表的第一项进行复制 | 141 | | device | 否 | 空为PC | 参考 puppeteer/DeviceDescriptors.js,可以设置为 'iPhone 6 Plus' | 142 | | debug | 否 | false | 是否开启调试开关 | 143 | | debugTime | 否 | 0 | 调试模式下,页面停留在骨架图的时间 | 144 | | minGrayBlockWidth | 否 | 0 | 最小处理灰色块的宽度 | 145 | | minGrayPseudoWidth | 否 | 0 | 最小处理伪类宽 | 146 | 147 | ## dom 节点属性 148 | 149 | 这是获取优质骨架图的要点,通过设置以下几个 dom 节点属性,在骨架图中对某些节点进行移除、忽略和指定背景色的操作,去除冗余节点的干扰,从而使得骨架图效果达到最佳。 150 | 151 | | 参数名称 | 说明 | 152 | | --- | --- | 153 | | data-skeleton-remove | 指定进行移除的 dom 节点属性 | 154 | | data-skeleton-bgcolor | 指定在某 dom 节点中添加的背景色 | 155 | | data-skeleton-ignore | 指定忽略不进行任何处理的 dom 节点属性 | 156 | | data-skeleton-empty | 将某dom的innerHTML置为空字符串 | 157 | 158 | 示例: 159 | 160 | ```html 161 |
abc
162 |
abc
163 |
abc
164 |
abc
165 | ``` 166 | 167 | ## 本地开发 168 | 169 | ### 安装依赖 170 | 171 | ```bash 172 | $ git clone git@github.com:kaola-fed/awesome-skeleton.git 173 | $ cd awesome-skeleton && npm i 174 | ``` 175 | 176 | ### 运行项目 177 | 178 | 由于生成骨架图的代码是通过动态脚本插入的,所以需要通过 rollup 将 src/script 中的代码打包到 src/script/dist/index.js 中。首先启动 rollup 打包 179 | 180 | ```bash 181 | $ npm run dev 182 | ``` 183 | 184 | 修改 demo/index.js 中的配置,从而生成不同页面的骨架图: 185 | 186 | ```bash 187 | $ cd demo 188 | $ node index.js 189 | ``` 190 | 191 | # 感谢 192 | 193 | - [puppeteer](https://github.com/GoogleChrome/puppeteer) 194 | - [page-skeleton-webpack-plugin](https://github.com/ElemeFE/page-skeleton-webpack-plugin) 195 | 196 | [npm-image]: https://img.shields.io/npm/v/awesome-skeleton.svg?style=flat-square&logo=npm 197 | [npm-url]: https://npmjs.org/package/awesome-skeleton 198 | [travis-image]: https://img.shields.io/travis/kaola-fed/awesome-skeleton/master.svg?style=flat-square&logo=travis 199 | [travis-url]: https://travis-ci.org/kaola-fed/awesome-skeleton 200 | [daviddm-image]: https://img.shields.io/david/kaola-fed/awesome-skeleton.svg?style=flat-square 201 | [daviddm-url]: https://david-dm.org/kaola-fed/awesome-skeleton 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # awesome-skeleton 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Build status][travis-image]][travis-url] 5 | [![Dependency status][daviddm-image]][daviddm-url] 6 | 7 | > Skeleton generation tool 8 | 9 | English | [简体中文](./README-zh_CN.md) 10 | 11 | 12 | 13 | ## Contributors 14 | 15 | |[
zivyangll](https://github.com/zivyangll)
| 16 | | :---: | 17 | 18 | 19 | 20 | ## Effect preview 21 | 22 | View online effects: [Kaola cart](https://m-buy.kaola.com/cart.html): 23 | 24 | ![kaola skeleton](https://user-images.githubusercontent.com/11460601/65293821-19225f00-db8f-11e9-802f-ef34458e9c58.jpg) 25 | 26 | ## Description 27 | * skeleton generation component, only for the node side. This component provides two capabilities for skeleton generation and skeleton template injection. 28 | * Skeletal diagram generation logic: Open the page address by using the Puppeteer headless browser by passing in the page address, perform gray background processing on the first screen image and text of the page, and then take a screenshot of the first screen of the page to generate a compressed base64 png image. 29 | 30 | ## Installation 31 | 32 | ### Global installation 33 | 34 | ```bash 35 | $ npm i awesome-skeleton -g 36 | ``` 37 | 38 | ### Installation in the project 39 | ```bash 40 | $ npm i awesome-skeleton -D 41 | ``` 42 | 43 | ## Instructions 44 | 45 | ### Adding a configuration file 46 | 47 | skeleton.config.json: 48 | 49 | ```json 50 | { 51 | "pageName": "baidu", 52 | "pageUrl": "https://www.baidu.com", 53 | "openRepeatList": false, 54 | "device": "iPhone X", 55 | "minGrayBlockWidth": 80, 56 | "minGrayPseudoWidth": 10, 57 | "debug": true, 58 | "debugTime": 3000, 59 | "cookies": [ 60 | { 61 | "domain": ".baidu.com", 62 | "expirationDate": 1568267131.555328, 63 | "hostOnly": false, 64 | "httpOnly": false, 65 | "name": "BDORZ", 66 | "path": "/", 67 | "sameSite": "unspecified", 68 | "secure": false, 69 | "session": false, 70 | "storeId": "0", 71 | "value": "yyyyyyyyy", 72 | "id": 2 73 | } 74 | ] 75 | } 76 | ``` 77 | 78 | ### Globally generated skeleton 79 | 80 | ```bash 81 | $ skeleton -c ./skeleton.config.json 82 | ``` 83 | 84 | After the page DomReady, a red button appears at the top of the page: Start generating the skeleton screen. 85 | 86 | After the build is complete, a skeleton-output file is generated in the run directory, which includes the skeleton screen png image, base64 text, and html file: 87 | - base64-baidu.png # skeleton picture 88 | - base64-baidu.txt # skeleton diagram Base64 encoding 89 | - base64-baidu.html # Final HTML generationL 90 | 91 | The html file can be used directly, copy the following location: 92 | 93 | ```html 94 | 95 | 96 | 97 | 98 | 99 | ``` 100 | 101 | note: 102 | - The skeleton is destroyed by default after onload event. 103 | - Manual destruction method: 104 | 105 | ```js 106 | window.SKELETON && SKELETON.destroy(); 107 | ``` 108 | 109 | **Of course, you can also use the generated Base64 image directly in your project** 110 | 111 | ### Creating a skeleton screen in the project 112 | 113 | Add a script to package.json : 114 | 115 | ``` 116 | "scripts": { 117 |    "skeleton": "skeleton -c ./skeleton.config.json" 118 | } 119 | ``` 120 | 121 | Generate skeleton screen:: 122 | 123 | ```bash 124 | $ npm run skeleton 125 | ``` 126 | 127 | ### Solve the login status 128 | 129 | If the page requires a login, you'll need to download the Chrome plugin [EditThisCookie] (https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg) to copy the cookie into the configuration parameters. 130 | 131 | ## Parameters 132 | 133 | | Parameter Name | Required | Default | Description | 134 | | --- | --- | --- | --- | 135 | | pageUrl | Yes | - | Page address (this address must be accessible) | 136 | | pageName | no | output | page name (English only) | 137 | | cookies | no | | page cookies to resolve login status issues | 138 | OutputPath | no | skeleton-output | skeleton file output folder path, default to project skeleton-output | 139 | | openRepeatList | no | true | by default will copy the first item of each list | 140 | | device | no | empty for PC | reference puppeteer/DeviceDescriptors.js, can be set to 'iPhone 6 Plus' | 141 | | debug | no | false | turn on debug switch | 142 | | debugTime | No | 0 | Time in the debug mode, the page stays in the skeleton | 143 | | minGrayBlockWidth | No | 0 | Minimum processing width of gray blocks | 144 | | minGrayPseudoWidth | No | 0 | Minimum processing pseudo-class width | 145 | 146 | ## dom node attribute 147 | 148 | This is the main point of obtaining a high-quality skeleton. By setting the following dom node attributes, some nodes are removed, ignored, and specified in the skeleton to remove the interference of redundant nodes, thus making the skeleton effect Get the best. 149 | 150 | | Parameter Name | Description | 151 | | --- | --- | 152 | | data-skeleton-remove | Specifies the dom node properties to remove | 153 | | data-skeleton-bgcolor | Specify the background color added in a dom node | 154 | | data-skeleton-ignore | Specifies to ignore dom node properties without any processing | 155 | | data-skeleton-empty | Set a dom's innerHTML to an empty string | 156 | 157 | Example: 158 | 159 | ```html 160 |
abc
161 |
abc
162 |
abc
163 |
abc
164 | ``` 165 | 166 | ## development 167 | 168 | ### Installation dependencies 169 | 170 | ```bash 171 | $ git clone git@github.com:kaola-fed/awesome-skeleton.git 172 | $ cd awesome-skeleton && npm i 173 | ``` 174 | 175 | ### Running the project 176 | 177 | Since the code that generates the skeleton is inserted through dynamic scripts, the code in src/script needs to be packaged into src/script/dist/index.js by Rollup. 178 | 179 | ```bash 180 | $ npm run dev 181 | ``` 182 | 183 | Modify the configuration in demo/index.js to generate a skeleton of the different pages: 184 | 185 | ```bash 186 | $ cd demo 187 | $ node index.js 188 | ``` 189 | 190 | # Thanks 191 | 192 | - [puppeteer](https://github.com/GoogleChrome/puppeteer) 193 | - [page-skeleton-webpack-plugin](https://github.com/ElemeFE/page-skeleton-webpack-plugin) 194 | 195 | [npm-image]: https://img.shields.io/npm/v/awesome-skeleton.svg?style=flat-square&logo=npm 196 | [npm-url]: https://npmjs.org/package/awesome-skeleton 197 | [travis-image]: https://img.shields.io/travis/kaola-fed/awesome-skeleton/master.svg?style=flat-square&logo=travis 198 | [travis-url]: https://travis-ci.org/kaola-fed/awesome-skeleton 199 | [daviddm-image]: https://img.shields.io/david/kaola-fed/awesome-skeleton.svg?style=flat-square 200 | [daviddm-url]: https://david-dm.org/kaola-fed/awesome-skeleton 201 | -------------------------------------------------------------------------------- /bin/skeleton.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 4 | const { 5 | EOL, 6 | } = require('os'); 7 | const path = require('path'); 8 | const program = require('commander'); 9 | const _ = require('xutil'); 10 | const updateNotifier = require('update-notifier'); 11 | 12 | const { chalk } = _; 13 | const getSkeleton = require('../src'); 14 | const pkg = require('../package.json'); 15 | 16 | updateNotifier({ 17 | pkg, 18 | updateCheckInterval: 5000, // 5s 19 | }).notify(); 20 | 21 | 22 | program 23 | .option('--verbose', 'show more debugging information') 24 | .option('-c, --config ', 'set configuration file') 25 | .parse(process.argv); 26 | 27 | let options = {}; 28 | 29 | if (program.config) { 30 | const configFile = path.resolve(program.config); 31 | 32 | if (_.isExistedFile(configFile)) { 33 | console.log(`${EOL}configuration file: ${chalk.cyan(configFile)}`); 34 | options = Object.assign(options, require(configFile)); 35 | } 36 | } 37 | 38 | (async () => { 39 | try { 40 | await getSkeleton(options); 41 | 42 | const resultDir = path.join(process.cwd(), 'skeleton-output'); 43 | console.log('result files save in: ', chalk.cyan(resultDir)); 44 | } catch (error) { 45 | console.log(chalk.red(`${EOL}awesome-skeleton start unsuccessfully: ${error}${EOL}`)); 46 | return; 47 | } 48 | })(); 49 | 50 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | const getSkeleton = require('../src/index'); 2 | 3 | getSkeleton({ 4 | pageName: 'baidu', 5 | pageUrl: 'https://www.baidu.com', 6 | openRepeatList: false, 7 | device: 'iPhone X', // 为空则使用默认 PC 页面打开 8 | minGrayBlockWidth: 80, 9 | minGrayPseudoWidth: 10, 10 | debug: true, 11 | debugTime: 3000, 12 | cookies: [{ 13 | "domain": ".baidu.com", 14 | "expirationDate": 1568267131.555328, 15 | "hostOnly": false, 16 | "httpOnly": false, 17 | "name": "BDORZ", 18 | "path": "/", 19 | "sameSite": "unspecified", 20 | "secure": false, 21 | "session": false, 22 | "storeId": "0", 23 | "value": "yyyyyyyyy", 24 | "id": 2 25 | }], 26 | }).then(result => { 27 | console.log(result.html) 28 | }) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awesome-skeleton", 3 | "version": "0.1.5", 4 | "description": "骨架图生成器", 5 | "author": "", 6 | "main": "src/index.js", 7 | "bin": { 8 | "skeleton": "bin/skeleton.js" 9 | }, 10 | "scripts": { 11 | "dev": "rollup --config rollup.config.js --watch", 12 | "build": "rollup --config rollup.config.js", 13 | "lint": "npm run build && eslint . --fix", 14 | "test": "nyc mocha", 15 | "ci": "npm run lint && npm run test", 16 | "version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add .", 17 | "contributor": "git-contributor", 18 | "prepublishOnly": "np --yolo --no-publish" 19 | }, 20 | "dependencies": { 21 | "base64-img": "^1.0.4", 22 | "commander": "^3.0.1", 23 | "html-minifier": "^4.0.0", 24 | "images": "^3.0.2", 25 | "puppeteer": "^1.2.0", 26 | "update-notifier": "^3.0.1", 27 | "xutil": "^1.0.11" 28 | }, 29 | "devDependencies": { 30 | "@commitlint/config-conventional": "^8.2.0", 31 | "commitlint": "^8.2.0", 32 | "conventional-changelog-cli": "^2.0.23", 33 | "eslint": "^6.3.0", 34 | "eslint-config-egg": "^7.5.1", 35 | "git-contributor": "^1.0.10", 36 | "husky": "^3.0.5", 37 | "lint-staged": "^9.2.5", 38 | "mocha": "^6.2.0", 39 | "nyc": "^14.1.1", 40 | "rollup": "^1.20.3" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git@github.com:kaola-fed/awesome-skeleton.git" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 49 | "pre-commit": "lint-staged" 50 | } 51 | }, 52 | "lint-staged": { 53 | "*.js": [ 54 | "eslint --fix" 55 | ] 56 | }, 57 | "nyc": { 58 | "reporter": [ 59 | "lcov", 60 | "text" 61 | ] 62 | }, 63 | "commitlint": { 64 | "extends": [ 65 | "@commitlint/config-conventional" 66 | ] 67 | }, 68 | "bugs": { 69 | "url": "https://github.com/kaola-fed/awesome-skeleton/issues" 70 | }, 71 | "engines": { 72 | "node": ">=8.9.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | input: 'src/script/main.js', 3 | output: { 4 | file: 'src/script/dist/index.js', 5 | format: 'iife', 6 | name: 'AwesomeSkeleton', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const { 5 | saveScreenShot, 6 | } = require('./saveFile'); 7 | const openPage = require('./openPage'); 8 | const insertSkeleton = require('./insertSkeleton'); 9 | 10 | /** 11 | * Entry function 12 | * @param {Object} options Configuration 13 | * @param {String} options.pageName Page name (English only) 14 | * @param {String} options.pageUrl Page URL (must be accessible) 15 | * @param {String} options.outputPath Skeleton map file output folder path, output to the default project without filling 16 | * @param {Boolean} options.openRepeatList The first item of each list is copied by default, the default value is true 17 | * @param {Object} options.device reference puppeteer/DeviceDescriptors.js 18 | * @param {Number} options.minGrayBlockWidth Minimum processing width of gray blocks 19 | * @param {Number} options.minGrayPseudoWidth Minimum processing pseudo-class width 20 | * @param {Boolean} options.debug Whether to turn on debug mode 21 | * @param {Number} options.debugTime In debug mode, the time the page stays in the skeleton diagram 22 | */ 23 | const getSkeleton = async function(options) { 24 | // Parameter check 25 | if (!options.pageUrl) { 26 | console.warn('页面地址不能为空!'); 27 | return false; 28 | } 29 | 30 | // Set default parameters 31 | options.pageName = options.pageName ? options.pageName : 'output'; 32 | options.outputPath = options.outputPath ? options.outputPath : path.join('skeleton-output'); 33 | 34 | // Create directory if there is no output directory 35 | if (!fs.existsSync(options.outputPath)) { 36 | fs.mkdirSync(options.outputPath); 37 | } 38 | 39 | // Open URL 40 | const { page, browser } = await openPage(options); 41 | 42 | // Processing the page as a skeleton page 43 | await page.evaluate(async options => { 44 | await window.AwesomeSkeleton.genSkeleton(options); 45 | }, options); 46 | 47 | // Screenshot and save as png and base64 txt files 48 | const skeletonImageBase64 = await saveScreenShot(page, options); 49 | 50 | // Inject the skeleton into the desired page 51 | const result = insertSkeleton(skeletonImageBase64, options); 52 | 53 | // Close the browser 54 | await browser.close(); 55 | 56 | return result; 57 | }; 58 | 59 | module.exports = getSkeleton; 60 | -------------------------------------------------------------------------------- /src/insertSkeleton.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const minify = require('html-minifier').minify; 4 | 5 | // Inject the skeleton into the template 6 | const insertSkeleton = (skeletonImageBase64, options) => { 7 | const skeletonHTMLPath = path.join(options.outputPath, `skeleton-${options.pageName}.html`); 8 | 9 | if (!skeletonImageBase64) { 10 | console.warn('The skeleton has not been generated yet'); 11 | return false; 12 | } 13 | 14 | const skeletonClass = 'skeleton-remove-after-first-request'; 15 | 16 | const content = ` 17 | 30 |
42 |
55 | `; 73 | 74 | // Code compression 75 | const minifyContent = minify(content, { 76 | minifyCSS: true, 77 | minifyJS: true, 78 | removeComments: true, 79 | }); 80 | 81 | // Write file 82 | fs.writeFileSync(skeletonHTMLPath, minifyContent, 'utf8', function(err) { 83 | if (err) return console.error(err); 84 | }); 85 | 86 | return { 87 | minHtml: minifyContent, 88 | html: content, 89 | img: skeletonImageBase64, 90 | }; 91 | }; 92 | 93 | module.exports = insertSkeleton; 94 | -------------------------------------------------------------------------------- /src/openPage.js: -------------------------------------------------------------------------------- 1 | const { 2 | sleep, 3 | genScriptContent, 4 | } = require('./util'); 5 | const puppeteer = require('puppeteer'); 6 | const devices = require('puppeteer/DeviceDescriptors'); 7 | 8 | // puppeteer/DeviceDescriptors, If no device style, need to customize 9 | const desktopDevice = { 10 | name: 'Desktop 1920x1080', 11 | userAgent: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.75 Safari/537.36', 12 | viewport: { 13 | width: 1920, 14 | height: 1080, 15 | }, 16 | }; 17 | 18 | const openPage = async options => { 19 | const browser = await puppeteer.launch({ 20 | headless: !options.debug, 21 | args: [ '--no-sandbox', '--disable-setuid-sandbox' ], 22 | }); 23 | const page = await browser.newPage(); 24 | const device = devices[options.device] || desktopDevice; 25 | await page.emulate(device); 26 | 27 | if (options.debug) { 28 | page.on('console', msg => console.log('PAGE LOG: ', msg.text())); 29 | page.on('warning', msg => console.log('PAGE WARN: ', JSON.stringify(msg))); 30 | page.on('error', msg => console.log('PAGE ERR: ', ...msg.args)); 31 | } 32 | 33 | // Write cookies to solve the login status problem 34 | if (options.cookies && options.cookies.length) { 35 | await page.setCookie(...options.cookies); 36 | await page.cookies(options.pageUrl); 37 | await sleep(1000); 38 | } 39 | 40 | // open page 41 | await page.goto(options.pageUrl); 42 | 43 | // Get the packaged script in src/script 44 | const scriptContent = await genScriptContent(); 45 | 46 | // Inject the script into the target page and get the global variable AwesomeSkeleton 47 | await page.addScriptTag({ content: scriptContent }); 48 | 49 | // Waiting for page execution to complete 50 | await sleep(2000); 51 | 52 | return { 53 | page, 54 | browser, 55 | }; 56 | }; 57 | 58 | module.exports = openPage; 59 | -------------------------------------------------------------------------------- /src/saveFile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const images = require('images'); 4 | const base64Img = require('base64-img'); 5 | 6 | const saveScreenShot = async (page, options) => { 7 | const screenshotPath = path.join(options.outputPath, `skeleton-${options.pageName}.png`); 8 | 9 | // First screen skeleton screenshot 10 | await page.screenshot({ 11 | path: screenshotPath, 12 | }); 13 | 14 | const imgWidth = options.device ? 375 : 1920; 15 | // Use images for image compression 16 | await images(screenshotPath).size(imgWidth).save(screenshotPath); 17 | 18 | const skeletonImageBase64 = base64Img.base64Sync(screenshotPath); 19 | 20 | const skeletonBase64Path = options.outputPath ? path.join(options.outputPath, './base64-' + options.pageName + '.txt') : null; 21 | if (skeletonBase64Path) { 22 | // Write the skeleton base64 png to a txt file 23 | fs.writeFileSync(skeletonBase64Path, skeletonImageBase64, err => { 24 | if (err) throw err; 25 | console.log(`The base64-${options.pageName}.txt file has been saved in path '${options.outputPath}' !`); 26 | }); 27 | } 28 | return skeletonImageBase64; 29 | }; 30 | 31 | module.exports = { 32 | saveScreenShot, 33 | }; 34 | -------------------------------------------------------------------------------- /src/script/constants.js: -------------------------------------------------------------------------------- 1 | // Skeleton main color 2 | export const MAIN_COLOR = '#EEEEEE'; 3 | export const MAIN_COLOR_RGB = 'rgb(238, 238, 238)'; 4 | 5 | // Pseudo-class style 6 | export const PSEUDO_CLASS = 'sk-pseudo'; 7 | 8 | // button style 9 | export const BUTTON_CLASS = 'sk-button'; 10 | 11 | // Transparent style 12 | export const TRANSPARENT_CLASS = 'sk-transparent'; 13 | 14 | // Transparent 1 pixel image 15 | export const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 16 | 17 | // text class 18 | export const SKELETON_TEXT_CLASS = 'skeleton-text-block-mark'; 19 | 20 | // List item Tag 21 | export const LIST_ITEM_TAG = [ 'LI', 'DT', 'DD' ]; 22 | -------------------------------------------------------------------------------- /src/script/dist/index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | // sleep function 5 | const sleep = ms => { 6 | return new Promise(resolve => setTimeout(resolve, ms)); 7 | }; 8 | 9 | // Check if the node is in the first screen 10 | const inViewPort = ele => { 11 | try { 12 | const rect = ele.getBoundingClientRect(); 13 | return rect.top < window.innerHeight && 14 | rect.left < window.innerWidth; 15 | 16 | } catch (e) { 17 | return true; 18 | } 19 | }; 20 | 21 | // Determine if the node has attributes 22 | const hasAttr = (ele, attr) => { 23 | try { 24 | return ele.hasAttribute(attr); 25 | } catch (e) { 26 | return false; 27 | } 28 | }; 29 | 30 | // Set node transparency 31 | const setOpacity = ele => { 32 | if (ele.style) { 33 | ele.style.opacity = 0; 34 | } 35 | }; 36 | 37 | // Unit conversion px -> rem 38 | const px2rem = px => { 39 | const pxValue = typeof px === 'string' ? parseInt(px, 10) : px; 40 | const htmlElementFontSize = getComputedStyle(document.documentElement).fontSize; 41 | 42 | return `${(pxValue / parseInt(htmlElementFontSize, 10))}rem`; 43 | }; 44 | 45 | // Batch setting element properties 46 | const setAttributes = (ele, attrs) => { 47 | Object.keys(attrs).forEach(k => ele.setAttribute(k, attrs[k])); 48 | }; 49 | 50 | // Delete element 51 | const removeElement = ele => { 52 | const parent = ele.parentNode; 53 | if (parent) { 54 | parent.removeChild(ele); 55 | } 56 | }; 57 | 58 | // Check the element pseudo-class to return the corresponding element and width 59 | const checkHasPseudoEle = ele => { 60 | if (!ele) return false; 61 | 62 | const beforeComputedStyle = getComputedStyle(ele, '::before'); 63 | const beforeContent = beforeComputedStyle.getPropertyValue('content'); 64 | const beforeWidth = parseFloat(beforeComputedStyle.getPropertyValue('width'), 10) || 0; 65 | const hasBefore = beforeContent && beforeContent !== 'none'; 66 | 67 | const afterComputedStyle = getComputedStyle(ele, '::after'); 68 | const afterContent = afterComputedStyle.getPropertyValue('content'); 69 | const afterWidth = parseFloat(afterComputedStyle.getPropertyValue('width'), 10) || 0; 70 | const hasAfter = afterContent && afterContent !== 'none'; 71 | 72 | const width = Math.max(beforeWidth, afterWidth); 73 | 74 | if (hasBefore || hasAfter) { 75 | return { hasBefore, hasAfter, ele, width }; 76 | } 77 | return false; 78 | }; 79 | 80 | // Skeleton main color 81 | const MAIN_COLOR = '#EEEEEE'; 82 | const MAIN_COLOR_RGB = 'rgb(238, 238, 238)'; 83 | 84 | // Pseudo-class style 85 | const PSEUDO_CLASS = 'sk-pseudo'; 86 | 87 | // button style 88 | const BUTTON_CLASS = 'sk-button'; 89 | 90 | // Transparent style 91 | const TRANSPARENT_CLASS = 'sk-transparent'; 92 | 93 | // Transparent 1 pixel image 94 | const SMALLEST_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 95 | 96 | // text class 97 | const SKELETON_TEXT_CLASS = 'skeleton-text-block-mark'; 98 | 99 | // List item Tag 100 | const LIST_ITEM_TAG = [ 'LI', 'DT', 'DD' ]; 101 | 102 | function aHandler(node) { 103 | node.removeAttribute('href'); 104 | } 105 | 106 | function svgHandler(node) { 107 | const { width, height } = node.getBoundingClientRect(); 108 | 109 | // Remove elements if they are not visible 110 | if (width === 0 || height === 0 || node.getAttribute('aria-hidden') === 'true') { 111 | return removeElement(node); 112 | } 113 | 114 | // Clear node centent 115 | node.innerHTML = ''; 116 | 117 | // Set style 118 | Object.assign(node.style, { 119 | width: px2rem(parseInt(width)), 120 | height: px2rem(parseInt(height)), 121 | }); 122 | } 123 | 124 | function imgHandler(node) { 125 | const { width, height } = node.getBoundingClientRect(); 126 | 127 | setAttributes(node, { 128 | width, 129 | height, 130 | src: SMALLEST_BASE64, 131 | }); 132 | 133 | node.style.backgroundColor = MAIN_COLOR; 134 | } 135 | 136 | function buttonHandler(node) { 137 | if (!node.tagName) return; 138 | 139 | node.classList.add(BUTTON_CLASS); 140 | 141 | let { backgroundColor: bgColor, width, height } = getComputedStyle(node); 142 | 143 | bgColor = bgColor === 'rgba(0, 0, 0, 0)' ? MAIN_COLOR : bgColor; 144 | 145 | node.style.backgroundColor = bgColor; 146 | node.style.color = bgColor; 147 | node.style.borderColor = bgColor; 148 | node.style.width = width; 149 | node.style.height = height; 150 | 151 | // Clear button content 152 | node.innerHTML = ''; 153 | } 154 | 155 | function getTextWidth(ele, style) { 156 | const MOCK_TEXT_ID = 'skeleton-text-id'; 157 | let offScreenParagraph = document.querySelector(`#${MOCK_TEXT_ID}`); 158 | if (!offScreenParagraph) { 159 | const wrapper = document.createElement('p'); 160 | offScreenParagraph = document.createElement('span'); 161 | Object.assign(wrapper.style, { 162 | width: '10000px', 163 | position: 'absolute', 164 | top: '0', 165 | }); 166 | offScreenParagraph.id = MOCK_TEXT_ID; 167 | offScreenParagraph.style.visibility = 'hidden'; 168 | wrapper.appendChild(offScreenParagraph); 169 | document.body.appendChild(wrapper); 170 | } 171 | Object.assign(offScreenParagraph.style, style); 172 | ele.childNodes && setStylesInNode(ele.childNodes); 173 | offScreenParagraph.innerHTML = ele.innerHTML; 174 | return offScreenParagraph.getBoundingClientRect().width; 175 | } 176 | 177 | function setStylesInNode(nodes) { 178 | Array.from(nodes).forEach(node => { 179 | if (!node || !node.tagName) return; 180 | const comStyle = getComputedStyle(node); 181 | Object.assign(node.style, { 182 | marginLeft: comStyle.marginLeft, 183 | marginRight: comStyle.marginRight, 184 | marginTop: comStyle.marginTop, 185 | marginBottom: comStyle.marginBottom, 186 | paddingLeft: comStyle.paddingLeft, 187 | paddingRight: comStyle.paddingRight, 188 | paddingTop: comStyle.paddingTop, 189 | paddingBottom: comStyle.paddingBottom, 190 | fontSize: comStyle.fontSize, 191 | lineHeight: comStyle.lineHeight, 192 | position: comStyle.position, 193 | textAlign: comStyle.textAlign, 194 | wordSpacing: comStyle.wordSpacing, 195 | wordBreak: comStyle.wordBreak, 196 | dispaly: comStyle.dispaly, 197 | boxSizing: comStyle.boxSizing, 198 | }); 199 | 200 | if (node.childNodes) { 201 | setStylesInNode(node.childNodes); 202 | } 203 | }); 204 | } 205 | 206 | function addTextMask(paragraph, { 207 | textAlign, 208 | lineHeight, 209 | paddingBottom, 210 | paddingLeft, 211 | paddingRight, 212 | }, maskWidthPercent = 0.5) { 213 | 214 | let left; 215 | let right; 216 | switch (textAlign) { 217 | case 'center': 218 | left = document.createElement('span'); 219 | right = document.createElement('span'); 220 | [ left, right ].forEach(mask => { 221 | Object.assign(mask.style, { 222 | display: 'inline-block', 223 | width: `${maskWidthPercent / 2 * 100}%`, 224 | height: lineHeight, 225 | background: '#fff', 226 | position: 'absolute', 227 | bottom: paddingBottom, 228 | }); 229 | }); 230 | left.style.left = paddingLeft; 231 | right.style.right = paddingRight; 232 | paragraph.appendChild(left); 233 | paragraph.appendChild(right); 234 | break; 235 | case 'right': 236 | left = document.createElement('span'); 237 | Object.assign(left.style, { 238 | display: 'inline-block', 239 | width: `${maskWidthPercent * 100}%`, 240 | height: lineHeight, 241 | background: '#fff', 242 | position: 'absolute', 243 | bottom: paddingBottom, 244 | left: paddingLeft, 245 | }); 246 | paragraph.appendChild(left); 247 | break; 248 | case 'left': 249 | default: 250 | right = document.createElement('span'); 251 | Object.assign(right.style, { 252 | display: 'inline-block', 253 | width: `${maskWidthPercent * 100}%`, 254 | height: lineHeight, 255 | background: '#fff', 256 | position: 'absolute', 257 | bottom: paddingBottom, 258 | right: paddingRight, 259 | }); 260 | paragraph.appendChild(right); 261 | break; 262 | } 263 | } 264 | 265 | function handleTextStyle(ele, width) { 266 | const comStyle = getComputedStyle(ele); 267 | let { 268 | lineHeight, 269 | paddingTop, 270 | paddingRight, 271 | paddingBottom, 272 | paddingLeft, 273 | position: pos, 274 | fontSize, 275 | textAlign, 276 | wordSpacing, 277 | wordBreak, 278 | } = comStyle; 279 | if (!/\d/.test(lineHeight)) { 280 | const fontSizeNum = parseInt(fontSize, 10) || 14; 281 | lineHeight = `${fontSizeNum * 1.4}px`; 282 | } 283 | 284 | const position = [ 'fixed', 'absolute', 'flex' ].find(p => p === pos) ? pos : 'relative'; 285 | 286 | const height = ele.offsetHeight; 287 | // Round down 288 | let lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) || 0; 289 | 290 | lineCount = lineCount < 1.5 ? 1 : lineCount; 291 | 292 | const textHeightRatio = 0.6; // Default 293 | 294 | // Add text block class name tag 295 | ele.classList.add(SKELETON_TEXT_CLASS); 296 | 297 | Object.assign(ele.style, { 298 | backgroundImage: `linear-gradient( 299 | transparent ${(1 - textHeightRatio) / 2 * 100}%, 300 | ${MAIN_COLOR} 0%, 301 | ${MAIN_COLOR} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%, 302 | transparent 0% 303 | )`, 304 | backgroundSize: `100% ${px2rem(parseInt(lineHeight) * 1.1)}`, 305 | position, 306 | }); 307 | 308 | // add white mask 309 | if (lineCount > 1) { 310 | addTextMask(ele, Object.assign(JSON.parse(JSON.stringify(comStyle)), { 311 | lineHeight, 312 | })); 313 | } else { 314 | const textWidth = getTextWidth(ele, { 315 | fontSize, 316 | lineHeight, 317 | wordBreak, 318 | wordSpacing, 319 | }); 320 | const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10)); 321 | ele.style.backgroundSize = `${textWidthPercent * 100}% 100%`; 322 | switch (textAlign) { 323 | case 'left': 324 | break; 325 | case 'right': 326 | ele.style.backgroundPositionX = '100%'; 327 | break; 328 | default: // center 329 | ele.style.backgroundPositionX = '50%'; 330 | break; 331 | } 332 | } 333 | } 334 | 335 | function textHandler(ele, options) { 336 | const { 337 | width, 338 | } = ele.getBoundingClientRect(); 339 | 340 | // Elements with a width less than N are not handled 341 | const minGrayBlockWidth = options.minGrayBlockWidth || 30; 342 | if (width <= minGrayBlockWidth) { 343 | return setOpacity(ele); 344 | } 345 | 346 | // If it is a button, it ends early 347 | const isBtn = /(btn)|(button)/g.test(ele.getAttribute('class')); 348 | if (isBtn) { 349 | return buttonHandler(ele); 350 | } 351 | 352 | // Handling text styles 353 | handleTextStyle(ele, width); 354 | } 355 | 356 | const listHandler = (node, options) => { 357 | if (!options.openRepeatList || !node.children.length) return; 358 | 359 | const children = node.children; 360 | const len = Array.from(children).filter(child => LIST_ITEM_TAG.indexOf(child.tagName) > -1).length; 361 | 362 | if (len === 0) return false; 363 | 364 | const firstChild = children[0]; 365 | // Solve the bug that sometimes the ul element child element is not a specified list element. 366 | if (LIST_ITEM_TAG.indexOf(firstChild.tagName) === -1) { 367 | return listHandler(firstChild, options); 368 | } 369 | 370 | // Keep only the first list element 371 | Array.from(children).forEach((li, index) => { 372 | if (index > 0) { 373 | removeElement(li); 374 | } 375 | }); 376 | 377 | // Set all sibling elements of LI to the same element to ensure that the generated page skeleton is neat 378 | for (let i = 1; i < len; i++) { 379 | node.appendChild(firstChild.cloneNode(true)); 380 | } 381 | }; 382 | 383 | function emptyHandler(node) { 384 | node.innerHTML = ''; 385 | 386 | let classNameArr = node.className && node.className.split(' '); 387 | classNameArr = classNameArr.map(item => { 388 | return '.' + item; 389 | }); 390 | const className = classNameArr.join(''); 391 | const id = node.id ? '#' + node.id : ''; 392 | const query = className || id; 393 | 394 | if (!query) return; 395 | 396 | let styleSheet; 397 | 398 | for (const item of document.styleSheets) { 399 | if (!item.href) { 400 | styleSheet = item; 401 | return; 402 | } 403 | } 404 | 405 | try { 406 | styleSheet && styleSheet.insertRule(`${query}::before{content:'' !important;background:none !important;}`, 0); 407 | styleSheet && styleSheet.insertRule(`${query}::after{content:'' !important;background:none !important;}`, 0); 408 | } catch (e) { 409 | console.log('handleEmptyNode Error: ', JSON.stringify(e)); 410 | } 411 | } 412 | 413 | function styleHandler() { 414 | const skeletonBlockStyleEle = document.createElement('style'); 415 | 416 | skeletonBlockStyleEle.innerText = ` 417 | .${SKELETON_TEXT_CLASS}, 418 | .${SKELETON_TEXT_CLASS} * { 419 | background-origin: content-box; 420 | background-clip: content-box; 421 | background-color: transparent !important; 422 | color: transparent !important; 423 | background-repeat: repeat-y; 424 | } 425 | 426 | .${PSEUDO_CLASS}::before, 427 | .${PSEUDO_CLASS}::after { 428 | background: ${MAIN_COLOR} !important; 429 | background-image: none !important; 430 | color: transparent !important; 431 | border-color: transparent !important; 432 | border-radius: 0 !important; 433 | } 434 | 435 | .${BUTTON_CLASS} { 436 | box-shadow: none !important; 437 | } 438 | 439 | .${TRANSPARENT_CLASS}::before, 440 | .${TRANSPARENT_CLASS}::after { 441 | opacity: 0 !important; 442 | } 443 | `.replace(/\n/g, ''); 444 | 445 | document.body.prepend(skeletonBlockStyleEle); 446 | } 447 | 448 | function inputHandler(node) { 449 | node.removeAttribute('placeholder'); 450 | node.value = ''; 451 | } 452 | 453 | function scriptHandler(node) { 454 | removeElement(node); 455 | } 456 | 457 | function pseudoHandler(node, options) { 458 | if (!node.tagName) return; 459 | 460 | const pseudo = checkHasPseudoEle(node); 461 | 462 | if (!pseudo || !pseudo.ele) return; 463 | 464 | const { ele, width } = pseudo; 465 | 466 | // Width is less than the hiding threshold 467 | if (width < options.minGrayPseudoWidth) { 468 | return ele.classList.add(TRANSPARENT_CLASS); 469 | } 470 | 471 | ele.classList.add(PSEUDO_CLASS); 472 | } 473 | 474 | function beforeHandler(node, options) { 475 | if (!node.tagName) return; 476 | 477 | // Handling empty elements of user tags 478 | if (hasAttr(node, 'data-skeleton-empty')) { 479 | emptyHandler(node); 480 | } 481 | 482 | // Width is less than the hiding threshold 483 | const { width } = node.getBoundingClientRect(); 484 | if (width < options.minGrayBlockWidth) { 485 | setOpacity(node); 486 | } 487 | 488 | const ComputedStyle = getComputedStyle(node); 489 | 490 | // The background image is changed to the main color 491 | if (ComputedStyle.backgroundImage !== 'none') { 492 | node.style.backgroundImage = 'none'; 493 | node.style.background = MAIN_COLOR; 494 | } 495 | 496 | // The Shadow is changed to the main color 497 | if (ComputedStyle.boxShadow !== 'none') { 498 | const oldBoxShadow = ComputedStyle.boxShadow; 499 | const newBoxShadow = oldBoxShadow.replace(/^rgb.*\)/, MAIN_COLOR_RGB); 500 | node.style.boxShadow = newBoxShadow; 501 | } 502 | 503 | // The border is changed to the main color 504 | if (ComputedStyle.borderColor) { 505 | node.style.borderColor = MAIN_COLOR; 506 | } 507 | 508 | // Set the background color of the user class 509 | const bgColor = node.getAttribute('data-skeleton-bgcolor'); 510 | if (bgColor) { 511 | node.style.backgroundColor = bgColor; 512 | node.style.color = 'transparent'; 513 | } 514 | } 515 | 516 | window.AwesomeSkeleton = { 517 | // Entry function 518 | async genSkeleton(options) { 519 | this.options = options; 520 | if (options.debug) { 521 | await this.debugGenSkeleton(options); 522 | } else { 523 | await this.startGenSkeleton(); 524 | } 525 | }, 526 | 527 | // Start generating the skeleton 528 | async startGenSkeleton() { 529 | this.init(); 530 | try { 531 | this.handleNode(document.body); 532 | } catch (e) { 533 | console.log('==genSkeleton Error==\n', e.message, e.stack); 534 | } 535 | }, 536 | 537 | // The Debug mode generates a skeleton diagram for debugging. 538 | // There will be a button at the top of the page, and click to generate a skeleton map. 539 | async debugGenSkeleton(options) { 540 | const switchWrapElement = document.createElement('div'); 541 | switchWrapElement.style.height = '100px'; 542 | const switchElement = document.createElement('button'); 543 | switchElement.innerHTML = '开始生成骨架图'; 544 | Object.assign(switchElement.style, { 545 | position: 'fixed', 546 | top: 0, 547 | left: 0, 548 | width: '100%', 549 | zIndex: 9999, 550 | color: '#FFFFFF', 551 | background: 'red', 552 | fontSize: '30px', 553 | height: '100px', 554 | }); 555 | switchWrapElement.appendChild(switchElement); 556 | document.body.prepend(switchWrapElement); 557 | 558 | // Need to wait for event processing, so use Promise for packaging 559 | return new Promise((resolve, reject) => { 560 | try { 561 | switchElement.onclick = async () => { 562 | removeElement(switchWrapElement); 563 | await this.startGenSkeleton(); 564 | await sleep(options.debugTime || 0); 565 | resolve(); 566 | }; 567 | } catch (e) { 568 | console.error('==startGenSkeleton Error==', e); 569 | reject(e); 570 | } 571 | }); 572 | }, 573 | 574 | // Initialization processing DOM 575 | init() { 576 | this.cleanSkeletonContainer(); 577 | styleHandler(); 578 | }, 579 | 580 | // Remove skeleton image html and style from the page to avoid interference 581 | cleanSkeletonContainer() { 582 | const skeletonWrap = document.body.querySelector('#nozomi-skeleton-html-style-container'); 583 | if (skeletonWrap) { 584 | removeElement(skeletonWrap); 585 | } 586 | }, 587 | 588 | /** 589 | * Processing text nodes 590 | * @param {*} node Node 591 | * @return {Boolean} True means that processing has been completed, false means that processing still needs to be continued 592 | */ 593 | handleText(node) { 594 | const tagName = node.tagName && node.tagName.toUpperCase(); 595 | 596 | // Processing
xxx
or xxx 597 | if (node.childNodes && node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) { 598 | textHandler(node, this.options); 599 | return true; 600 | } 601 | 602 | // Processing xxx,change to xxx 603 | if (node && node.nodeType === 3 && node.textContent) { 604 | const parent = node.parentNode; 605 | // Determine if it has been processed 606 | if (!parent.classList.contains(SKELETON_TEXT_CLASS)) { 607 | // It is plain text itself and needs to be replaced with a node 608 | const textContent = node.textContent.replace(/[\r\n]/g, '').trim(); 609 | if (textContent) { 610 | const tmpNode = document.createElement('i'); 611 | tmpNode.classList.add(SKELETON_TEXT_CLASS); 612 | tmpNode.innerText = textContent; 613 | node.parentNode.replaceChild(tmpNode, node); 614 | textHandler(tmpNode, this.options); 615 | return true; 616 | } 617 | } 618 | } 619 | 620 | // Processing 111222 111 621 | if (tagName === 'SPAN' && node.innerHTML) { 622 | // Process image and background image first 623 | this.handleImages(node.childNodes); 624 | 625 | textHandler(node, this.options); 626 | return true; 627 | } 628 | 629 | return false; 630 | }, 631 | 632 | // The text nodes are processed uniformly, and the background image, IMG, SVG, etc. need to be processed again. 633 | handleImages(nodes) { 634 | if (!nodes) return; 635 | 636 | Array.from(nodes).forEach(node => { 637 | if (hasAttr(node, 'data-skeleton-ignore')) return; 638 | 639 | beforeHandler(node, this.options); 640 | pseudoHandler(node, this.options); 641 | const tagName = node.tagName && node.tagName.toUpperCase(); 642 | if (tagName === 'IMG') { 643 | imgHandler(node); 644 | } else if (tagName === 'SVG') { 645 | svgHandler(node); 646 | } else { 647 | this.handleImages(node.childNodes); 648 | } 649 | }); 650 | }, 651 | 652 | // Processing node list 653 | handleNodes(nodes) { 654 | if (!nodes.length) return; 655 | 656 | Array.from(nodes).forEach(node => { 657 | this.handleNode(node); 658 | }); 659 | }, 660 | 661 | // Processing a single node 662 | handleNode(node) { 663 | if (!node) return; 664 | 665 | // Delete elements that are not in first screen, or marked for deletion 666 | if (!inViewPort(node) || hasAttr(node, 'data-skeleton-remove')) { 667 | return removeElement(node); 668 | } 669 | 670 | // Handling elements that are ignored by user tags -> End 671 | const ignore = hasAttr(node, 'data-skeleton-ignore') || node.tagName === 'STYLE'; 672 | if (ignore) return; 673 | 674 | // Preprocessing some styles 675 | beforeHandler(node, this.options); 676 | 677 | // Preprocessing pseudo-class style 678 | pseudoHandler(node, this.options); 679 | 680 | const tagName = node.tagName && node.tagName.toUpperCase(); 681 | const isBtn = tagName && (tagName === 'BUTTON' || /(btn)|(button)/g.test(node.getAttribute('class'))); 682 | 683 | let isCompleted = false; 684 | switch (tagName) { 685 | case 'SCRIPT': 686 | scriptHandler(node); 687 | break; 688 | case 'IMG': 689 | imgHandler(node); 690 | break; 691 | case 'SVG': 692 | svgHandler(node); 693 | break; 694 | case 'INPUT': 695 | inputHandler(node); 696 | break; 697 | case 'BUTTON': // Button processing ends once 698 | buttonHandler(node); 699 | break; 700 | case 'UL': 701 | case 'OL': 702 | case 'DL': 703 | listHandler(node, this.options); 704 | break; 705 | case 'A': // A label processing is placed behind to prevent IMG from displaying an exception 706 | aHandler(node); 707 | break; 708 | } 709 | 710 | if (isBtn) { 711 | // Handle button styles, end directly after processing 712 | buttonHandler(node); 713 | } else { 714 | // Other nodes are processed as TEXT 715 | isCompleted = this.handleText(node); 716 | } 717 | 718 | // If it is a button and has not been processed by handleText, then the child node is processed 719 | if (!isBtn && !isCompleted) { 720 | this.handleNodes(node.childNodes); 721 | } 722 | }, 723 | }; 724 | 725 | }()); 726 | -------------------------------------------------------------------------------- /src/script/handler/a.js: -------------------------------------------------------------------------------- 1 | function aHandler(node) { 2 | node.removeAttribute('href'); 3 | } 4 | 5 | export default aHandler; 6 | -------------------------------------------------------------------------------- /src/script/handler/before.js: -------------------------------------------------------------------------------- 1 | import { 2 | MAIN_COLOR, 3 | MAIN_COLOR_RGB, 4 | } from '../constants'; 5 | 6 | import { 7 | hasAttr, 8 | setOpacity, 9 | } from '../util'; 10 | 11 | import handlerEmpty from './empty.js'; 12 | 13 | function beforeHandler(node, options) { 14 | if (!node.tagName) return; 15 | 16 | // Handling empty elements of user tags 17 | if (hasAttr(node, 'data-skeleton-empty')) { 18 | handlerEmpty(node); 19 | } 20 | 21 | // Width is less than the hiding threshold 22 | const { width } = node.getBoundingClientRect(); 23 | if (width < options.minGrayBlockWidth) { 24 | setOpacity(node); 25 | } 26 | 27 | const ComputedStyle = getComputedStyle(node); 28 | 29 | // The background image is changed to the main color 30 | if (ComputedStyle.backgroundImage !== 'none') { 31 | node.style.backgroundImage = 'none'; 32 | node.style.background = MAIN_COLOR; 33 | } 34 | 35 | // The Shadow is changed to the main color 36 | if (ComputedStyle.boxShadow !== 'none') { 37 | const oldBoxShadow = ComputedStyle.boxShadow; 38 | const newBoxShadow = oldBoxShadow.replace(/^rgb.*\)/, MAIN_COLOR_RGB); 39 | node.style.boxShadow = newBoxShadow; 40 | } 41 | 42 | // The border is changed to the main color 43 | if (ComputedStyle.borderColor) { 44 | node.style.borderColor = MAIN_COLOR; 45 | } 46 | 47 | // Set the background color of the user class 48 | const bgColor = node.getAttribute('data-skeleton-bgcolor'); 49 | if (bgColor) { 50 | node.style.backgroundColor = bgColor; 51 | node.style.color = 'transparent'; 52 | } 53 | } 54 | 55 | export default beforeHandler; 56 | -------------------------------------------------------------------------------- /src/script/handler/button.js: -------------------------------------------------------------------------------- 1 | import { 2 | BUTTON_CLASS, 3 | MAIN_COLOR, 4 | } from '../constants'; 5 | 6 | function buttonHandler(node) { 7 | if (!node.tagName) return; 8 | 9 | node.classList.add(BUTTON_CLASS); 10 | 11 | let { backgroundColor: bgColor, width, height } = getComputedStyle(node); 12 | 13 | bgColor = bgColor === 'rgba(0, 0, 0, 0)' ? MAIN_COLOR : bgColor; 14 | 15 | node.style.backgroundColor = bgColor; 16 | node.style.color = bgColor; 17 | node.style.borderColor = bgColor; 18 | node.style.width = width; 19 | node.style.height = height; 20 | 21 | // Clear button content 22 | node.innerHTML = ''; 23 | } 24 | 25 | export default buttonHandler; 26 | -------------------------------------------------------------------------------- /src/script/handler/empty.js: -------------------------------------------------------------------------------- 1 | function emptyHandler(node) { 2 | node.innerHTML = ''; 3 | 4 | let classNameArr = node.className && node.className.split(' '); 5 | classNameArr = classNameArr.map(item => { 6 | return '.' + item; 7 | }); 8 | const className = classNameArr.join(''); 9 | const id = node.id ? '#' + node.id : ''; 10 | const query = className || id; 11 | 12 | if (!query) return; 13 | 14 | let styleSheet; 15 | 16 | for (const item of document.styleSheets) { 17 | if (!item.href) { 18 | styleSheet = item; 19 | return; 20 | } 21 | } 22 | 23 | try { 24 | styleSheet && styleSheet.insertRule(`${query}::before{content:'' !important;background:none !important;}`, 0); 25 | styleSheet && styleSheet.insertRule(`${query}::after{content:'' !important;background:none !important;}`, 0); 26 | } catch (e) { 27 | console.log('handleEmptyNode Error: ', JSON.stringify(e)); 28 | } 29 | } 30 | 31 | export default emptyHandler; 32 | -------------------------------------------------------------------------------- /src/script/handler/img.js: -------------------------------------------------------------------------------- 1 | import { 2 | MAIN_COLOR, 3 | SMALLEST_BASE64, 4 | } from '../constants'; 5 | 6 | import { 7 | setAttributes, 8 | } from '../util'; 9 | 10 | function imgHandler(node) { 11 | const { width, height } = node.getBoundingClientRect(); 12 | 13 | setAttributes(node, { 14 | width, 15 | height, 16 | src: SMALLEST_BASE64, 17 | }); 18 | 19 | node.style.backgroundColor = MAIN_COLOR; 20 | } 21 | 22 | export default imgHandler; 23 | -------------------------------------------------------------------------------- /src/script/handler/index.js: -------------------------------------------------------------------------------- 1 | import a from './a'; 2 | import svg from './svg'; 3 | import img from './img'; 4 | import text from './text'; 5 | import list from './list'; 6 | import empty from './empty'; 7 | import style from './style'; 8 | import input from './input'; 9 | import button from './button'; 10 | import script from './script'; 11 | import pseudo from './pseudo'; 12 | import before from './before'; 13 | 14 | export { 15 | a, 16 | svg, 17 | img, 18 | text, 19 | list, 20 | empty, 21 | style, 22 | input, 23 | button, 24 | script, 25 | pseudo, 26 | before, 27 | }; 28 | -------------------------------------------------------------------------------- /src/script/handler/input.js: -------------------------------------------------------------------------------- 1 | function inputHandler(node) { 2 | node.removeAttribute('placeholder'); 3 | node.value = ''; 4 | } 5 | 6 | export default inputHandler; 7 | -------------------------------------------------------------------------------- /src/script/handler/list.js: -------------------------------------------------------------------------------- 1 | import { 2 | removeElement, 3 | } from '../util'; 4 | 5 | import { 6 | LIST_ITEM_TAG, 7 | } from '../constants'; 8 | 9 | const listHandler = (node, options) => { 10 | if (!options.openRepeatList || !node.children.length) return; 11 | 12 | const children = node.children; 13 | const len = Array.from(children).filter(child => LIST_ITEM_TAG.indexOf(child.tagName) > -1).length; 14 | 15 | if (len === 0) return false; 16 | 17 | const firstChild = children[0]; 18 | // Solve the bug that sometimes the ul element child element is not a specified list element. 19 | if (LIST_ITEM_TAG.indexOf(firstChild.tagName) === -1) { 20 | return listHandler(firstChild, options); 21 | } 22 | 23 | // Keep only the first list element 24 | Array.from(children).forEach((li, index) => { 25 | if (index > 0) { 26 | removeElement(li); 27 | } 28 | }); 29 | 30 | // Set all sibling elements of LI to the same element to ensure that the generated page skeleton is neat 31 | for (let i = 1; i < len; i++) { 32 | node.appendChild(firstChild.cloneNode(true)); 33 | } 34 | }; 35 | 36 | export default listHandler; 37 | -------------------------------------------------------------------------------- /src/script/handler/pseudo.js: -------------------------------------------------------------------------------- 1 | import { 2 | checkHasPseudoEle, 3 | } from '../util'; 4 | 5 | import { 6 | PSEUDO_CLASS, 7 | TRANSPARENT_CLASS, 8 | } from '../constants'; 9 | 10 | function pseudoHandler(node, options) { 11 | if (!node.tagName) return; 12 | 13 | const pseudo = checkHasPseudoEle(node); 14 | 15 | if (!pseudo || !pseudo.ele) return; 16 | 17 | const { ele, width } = pseudo; 18 | 19 | // Width is less than the hiding threshold 20 | if (width < options.minGrayPseudoWidth) { 21 | return ele.classList.add(TRANSPARENT_CLASS); 22 | } 23 | 24 | ele.classList.add(PSEUDO_CLASS); 25 | } 26 | 27 | export default pseudoHandler; 28 | -------------------------------------------------------------------------------- /src/script/handler/script.js: -------------------------------------------------------------------------------- 1 | import { 2 | removeElement, 3 | } from '../util'; 4 | 5 | function scriptHandler(node) { 6 | removeElement(node); 7 | } 8 | 9 | export default scriptHandler; 10 | -------------------------------------------------------------------------------- /src/script/handler/style.js: -------------------------------------------------------------------------------- 1 | import { 2 | MAIN_COLOR, 3 | PSEUDO_CLASS, 4 | BUTTON_CLASS, 5 | TRANSPARENT_CLASS, 6 | SKELETON_TEXT_CLASS, 7 | } from '../constants'; 8 | 9 | function styleHandler() { 10 | const skeletonBlockStyleEle = document.createElement('style'); 11 | 12 | skeletonBlockStyleEle.innerText = ` 13 | .${SKELETON_TEXT_CLASS}, 14 | .${SKELETON_TEXT_CLASS} * { 15 | background-origin: content-box; 16 | background-clip: content-box; 17 | background-color: transparent !important; 18 | color: transparent !important; 19 | background-repeat: repeat-y; 20 | } 21 | 22 | .${PSEUDO_CLASS}::before, 23 | .${PSEUDO_CLASS}::after { 24 | background: ${MAIN_COLOR} !important; 25 | background-image: none !important; 26 | color: transparent !important; 27 | border-color: transparent !important; 28 | border-radius: 0 !important; 29 | } 30 | 31 | .${BUTTON_CLASS} { 32 | box-shadow: none !important; 33 | } 34 | 35 | .${TRANSPARENT_CLASS}::before, 36 | .${TRANSPARENT_CLASS}::after { 37 | opacity: 0 !important; 38 | } 39 | `.replace(/\n/g, ''); 40 | 41 | document.body.prepend(skeletonBlockStyleEle); 42 | } 43 | 44 | export default styleHandler; 45 | -------------------------------------------------------------------------------- /src/script/handler/svg.js: -------------------------------------------------------------------------------- 1 | import { 2 | px2rem, 3 | removeElement, 4 | } from '../util'; 5 | 6 | function svgHandler(node) { 7 | const { width, height } = node.getBoundingClientRect(); 8 | 9 | // Remove elements if they are not visible 10 | if (width === 0 || height === 0 || node.getAttribute('aria-hidden') === 'true') { 11 | return removeElement(node); 12 | } 13 | 14 | // Clear node centent 15 | node.innerHTML = ''; 16 | 17 | // Set style 18 | Object.assign(node.style, { 19 | width: px2rem(parseInt(width)), 20 | height: px2rem(parseInt(height)), 21 | }); 22 | } 23 | 24 | export default svgHandler; 25 | -------------------------------------------------------------------------------- /src/script/handler/text.js: -------------------------------------------------------------------------------- 1 | import { 2 | px2rem, 3 | setOpacity, 4 | } from '../util'; 5 | import { 6 | MAIN_COLOR, 7 | SKELETON_TEXT_CLASS, 8 | } from '../constants'; 9 | import handlerButton from './button'; 10 | 11 | function getTextWidth(ele, style) { 12 | const MOCK_TEXT_ID = 'skeleton-text-id'; 13 | let offScreenParagraph = document.querySelector(`#${MOCK_TEXT_ID}`); 14 | if (!offScreenParagraph) { 15 | const wrapper = document.createElement('p'); 16 | offScreenParagraph = document.createElement('span'); 17 | Object.assign(wrapper.style, { 18 | width: '10000px', 19 | position: 'absolute', 20 | top: '0', 21 | }); 22 | offScreenParagraph.id = MOCK_TEXT_ID; 23 | offScreenParagraph.style.visibility = 'hidden'; 24 | wrapper.appendChild(offScreenParagraph); 25 | document.body.appendChild(wrapper); 26 | } 27 | Object.assign(offScreenParagraph.style, style); 28 | ele.childNodes && setStylesInNode(ele.childNodes); 29 | offScreenParagraph.innerHTML = ele.innerHTML; 30 | return offScreenParagraph.getBoundingClientRect().width; 31 | } 32 | 33 | function setStylesInNode(nodes) { 34 | Array.from(nodes).forEach(node => { 35 | if (!node || !node.tagName) return; 36 | const comStyle = getComputedStyle(node); 37 | Object.assign(node.style, { 38 | marginLeft: comStyle.marginLeft, 39 | marginRight: comStyle.marginRight, 40 | marginTop: comStyle.marginTop, 41 | marginBottom: comStyle.marginBottom, 42 | paddingLeft: comStyle.paddingLeft, 43 | paddingRight: comStyle.paddingRight, 44 | paddingTop: comStyle.paddingTop, 45 | paddingBottom: comStyle.paddingBottom, 46 | fontSize: comStyle.fontSize, 47 | lineHeight: comStyle.lineHeight, 48 | position: comStyle.position, 49 | textAlign: comStyle.textAlign, 50 | wordSpacing: comStyle.wordSpacing, 51 | wordBreak: comStyle.wordBreak, 52 | dispaly: comStyle.dispaly, 53 | boxSizing: comStyle.boxSizing, 54 | }); 55 | 56 | if (node.childNodes) { 57 | setStylesInNode(node.childNodes); 58 | } 59 | }); 60 | } 61 | 62 | function addTextMask(paragraph, { 63 | textAlign, 64 | lineHeight, 65 | paddingBottom, 66 | paddingLeft, 67 | paddingRight, 68 | }, maskWidthPercent = 0.5) { 69 | 70 | let left; 71 | let right; 72 | switch (textAlign) { 73 | case 'center': 74 | left = document.createElement('span'); 75 | right = document.createElement('span'); 76 | [ left, right ].forEach(mask => { 77 | Object.assign(mask.style, { 78 | display: 'inline-block', 79 | width: `${maskWidthPercent / 2 * 100}%`, 80 | height: lineHeight, 81 | background: '#fff', 82 | position: 'absolute', 83 | bottom: paddingBottom, 84 | }); 85 | }); 86 | left.style.left = paddingLeft; 87 | right.style.right = paddingRight; 88 | paragraph.appendChild(left); 89 | paragraph.appendChild(right); 90 | break; 91 | case 'right': 92 | left = document.createElement('span'); 93 | Object.assign(left.style, { 94 | display: 'inline-block', 95 | width: `${maskWidthPercent * 100}%`, 96 | height: lineHeight, 97 | background: '#fff', 98 | position: 'absolute', 99 | bottom: paddingBottom, 100 | left: paddingLeft, 101 | }); 102 | paragraph.appendChild(left); 103 | break; 104 | case 'left': 105 | default: 106 | right = document.createElement('span'); 107 | Object.assign(right.style, { 108 | display: 'inline-block', 109 | width: `${maskWidthPercent * 100}%`, 110 | height: lineHeight, 111 | background: '#fff', 112 | position: 'absolute', 113 | bottom: paddingBottom, 114 | right: paddingRight, 115 | }); 116 | paragraph.appendChild(right); 117 | break; 118 | } 119 | } 120 | 121 | function handleTextStyle(ele, width) { 122 | const comStyle = getComputedStyle(ele); 123 | let { 124 | lineHeight, 125 | paddingTop, 126 | paddingRight, 127 | paddingBottom, 128 | paddingLeft, 129 | position: pos, 130 | fontSize, 131 | textAlign, 132 | wordSpacing, 133 | wordBreak, 134 | } = comStyle; 135 | if (!/\d/.test(lineHeight)) { 136 | const fontSizeNum = parseInt(fontSize, 10) || 14; 137 | lineHeight = `${fontSizeNum * 1.4}px`; 138 | } 139 | 140 | const position = [ 'fixed', 'absolute', 'flex' ].find(p => p === pos) ? pos : 'relative'; 141 | 142 | const height = ele.offsetHeight; 143 | // Round down 144 | let lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) || 0; 145 | 146 | lineCount = lineCount < 1.5 ? 1 : lineCount; 147 | 148 | const textHeightRatio = 0.6; // Default 149 | 150 | // Add text block class name tag 151 | ele.classList.add(SKELETON_TEXT_CLASS); 152 | 153 | Object.assign(ele.style, { 154 | backgroundImage: `linear-gradient( 155 | transparent ${(1 - textHeightRatio) / 2 * 100}%, 156 | ${MAIN_COLOR} 0%, 157 | ${MAIN_COLOR} ${((1 - textHeightRatio) / 2 + textHeightRatio) * 100}%, 158 | transparent 0% 159 | )`, 160 | backgroundSize: `100% ${px2rem(parseInt(lineHeight) * 1.1)}`, 161 | position, 162 | }); 163 | 164 | // add white mask 165 | if (lineCount > 1) { 166 | addTextMask(ele, Object.assign(JSON.parse(JSON.stringify(comStyle)), { 167 | lineHeight, 168 | })); 169 | } else { 170 | const textWidth = getTextWidth(ele, { 171 | fontSize, 172 | lineHeight, 173 | wordBreak, 174 | wordSpacing, 175 | }); 176 | const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10)); 177 | ele.style.backgroundSize = `${textWidthPercent * 100}% 100%`; 178 | switch (textAlign) { 179 | case 'left': 180 | break; 181 | case 'right': 182 | ele.style.backgroundPositionX = '100%'; 183 | break; 184 | default: // center 185 | ele.style.backgroundPositionX = '50%'; 186 | break; 187 | } 188 | } 189 | } 190 | 191 | function textHandler(ele, options) { 192 | const { 193 | width, 194 | } = ele.getBoundingClientRect(); 195 | 196 | // Elements with a width less than N are not handled 197 | const minGrayBlockWidth = options.minGrayBlockWidth || 30; 198 | if (width <= minGrayBlockWidth) { 199 | return setOpacity(ele); 200 | } 201 | 202 | // If it is a button, it ends early 203 | const isBtn = /(btn)|(button)/g.test(ele.getAttribute('class')); 204 | if (isBtn) { 205 | return handlerButton(ele); 206 | } 207 | 208 | // Handling text styles 209 | handleTextStyle(ele, width); 210 | } 211 | 212 | export default textHandler; 213 | -------------------------------------------------------------------------------- /src/script/main.js: -------------------------------------------------------------------------------- 1 | import { 2 | sleep, 3 | hasAttr, 4 | inViewPort, 5 | removeElement, 6 | } from './util'; 7 | 8 | import { 9 | SKELETON_TEXT_CLASS, 10 | } from './constants'; 11 | 12 | import * as handler from './handler/index'; 13 | 14 | window.AwesomeSkeleton = { 15 | // Entry function 16 | async genSkeleton(options) { 17 | this.options = options; 18 | if (options.debug) { 19 | await this.debugGenSkeleton(options); 20 | } else { 21 | await this.startGenSkeleton(); 22 | } 23 | }, 24 | 25 | // Start generating the skeleton 26 | async startGenSkeleton() { 27 | this.init(); 28 | try { 29 | this.handleNode(document.body); 30 | } catch (e) { 31 | console.log('==genSkeleton Error==\n', e.message, e.stack); 32 | } 33 | }, 34 | 35 | // The Debug mode generates a skeleton diagram for debugging. 36 | // There will be a button at the top of the page, and click to generate a skeleton map. 37 | async debugGenSkeleton(options) { 38 | const switchWrapElement = document.createElement('div'); 39 | switchWrapElement.style.height = '100px'; 40 | const switchElement = document.createElement('button'); 41 | switchElement.innerHTML = '开始生成骨架图'; 42 | Object.assign(switchElement.style, { 43 | position: 'fixed', 44 | top: 0, 45 | left: 0, 46 | width: '100%', 47 | zIndex: 9999, 48 | color: '#FFFFFF', 49 | background: 'red', 50 | fontSize: '30px', 51 | height: '100px', 52 | }); 53 | switchWrapElement.appendChild(switchElement); 54 | document.body.prepend(switchWrapElement); 55 | 56 | // Need to wait for event processing, so use Promise for packaging 57 | return new Promise((resolve, reject) => { 58 | try { 59 | switchElement.onclick = async () => { 60 | removeElement(switchWrapElement); 61 | await this.startGenSkeleton(); 62 | await sleep(options.debugTime || 0); 63 | resolve(); 64 | }; 65 | } catch (e) { 66 | console.error('==startGenSkeleton Error==', e); 67 | reject(e); 68 | } 69 | }); 70 | }, 71 | 72 | // Initialization processing DOM 73 | init() { 74 | this.cleanSkeletonContainer(); 75 | handler.style(); 76 | }, 77 | 78 | // Remove skeleton image html and style from the page to avoid interference 79 | cleanSkeletonContainer() { 80 | const skeletonWrap = document.body.querySelector('#nozomi-skeleton-html-style-container'); 81 | if (skeletonWrap) { 82 | removeElement(skeletonWrap); 83 | } 84 | }, 85 | 86 | /** 87 | * Processing text nodes 88 | * @param {*} node Node 89 | * @return {Boolean} True means that processing has been completed, false means that processing still needs to be continued 90 | */ 91 | handleText(node) { 92 | const tagName = node.tagName && node.tagName.toUpperCase(); 93 | 94 | // Processing
xxx
or xxx 95 | if (node.childNodes && node.childNodes.length === 1 && node.childNodes[0].nodeType === 3) { 96 | handler.text(node, this.options); 97 | return true; 98 | } 99 | 100 | // Processing xxx,change to xxx 101 | if (node && node.nodeType === 3 && node.textContent) { 102 | const parent = node.parentNode; 103 | // Determine if it has been processed 104 | if (!parent.classList.contains(SKELETON_TEXT_CLASS)) { 105 | // It is plain text itself and needs to be replaced with a node 106 | const textContent = node.textContent.replace(/[\r\n]/g, '').trim(); 107 | if (textContent) { 108 | const tmpNode = document.createElement('i'); 109 | tmpNode.classList.add(SKELETON_TEXT_CLASS); 110 | tmpNode.innerText = textContent; 111 | node.parentNode.replaceChild(tmpNode, node); 112 | handler.text(tmpNode, this.options); 113 | return true; 114 | } 115 | } 116 | } 117 | 118 | // Processing 111222 111 119 | if (tagName === 'SPAN' && node.innerHTML) { 120 | // Process image and background image first 121 | this.handleImages(node.childNodes); 122 | 123 | handler.text(node, this.options); 124 | return true; 125 | } 126 | 127 | return false; 128 | }, 129 | 130 | // The text nodes are processed uniformly, and the background image, IMG, SVG, etc. need to be processed again. 131 | handleImages(nodes) { 132 | if (!nodes) return; 133 | 134 | Array.from(nodes).forEach(node => { 135 | if (hasAttr(node, 'data-skeleton-ignore')) return; 136 | 137 | handler.before(node, this.options); 138 | handler.pseudo(node, this.options); 139 | const tagName = node.tagName && node.tagName.toUpperCase(); 140 | if (tagName === 'IMG') { 141 | handler.img(node); 142 | } else if (tagName === 'SVG') { 143 | handler.svg(node); 144 | } else { 145 | this.handleImages(node.childNodes); 146 | } 147 | }); 148 | }, 149 | 150 | // Processing node list 151 | handleNodes(nodes) { 152 | if (!nodes.length) return; 153 | 154 | Array.from(nodes).forEach(node => { 155 | this.handleNode(node); 156 | }); 157 | }, 158 | 159 | // Processing a single node 160 | handleNode(node) { 161 | if (!node) return; 162 | 163 | // Delete elements that are not in first screen, or marked for deletion 164 | if (!inViewPort(node) || hasAttr(node, 'data-skeleton-remove')) { 165 | return removeElement(node); 166 | } 167 | 168 | // Handling elements that are ignored by user tags -> End 169 | const ignore = hasAttr(node, 'data-skeleton-ignore') || node.tagName === 'STYLE'; 170 | if (ignore) return; 171 | 172 | // Preprocessing some styles 173 | handler.before(node, this.options); 174 | 175 | // Preprocessing pseudo-class style 176 | handler.pseudo(node, this.options); 177 | 178 | const tagName = node.tagName && node.tagName.toUpperCase(); 179 | const isBtn = tagName && (tagName === 'BUTTON' || /(btn)|(button)/g.test(node.getAttribute('class'))); 180 | 181 | let isCompleted = false; 182 | switch (tagName) { 183 | case 'SCRIPT': 184 | handler.script(node); 185 | break; 186 | case 'IMG': 187 | handler.img(node); 188 | break; 189 | case 'SVG': 190 | handler.svg(node); 191 | break; 192 | case 'INPUT': 193 | handler.input(node); 194 | break; 195 | case 'BUTTON': // Button processing ends once 196 | handler.button(node); 197 | break; 198 | case 'UL': 199 | case 'OL': 200 | case 'DL': 201 | handler.list(node, this.options); 202 | break; 203 | case 'A': // A label processing is placed behind to prevent IMG from displaying an exception 204 | handler.a(node); 205 | break; 206 | default: 207 | break; 208 | } 209 | 210 | if (isBtn) { 211 | // Handle button styles, end directly after processing 212 | handler.button(node); 213 | } else { 214 | // Other nodes are processed as TEXT 215 | isCompleted = this.handleText(node); 216 | } 217 | 218 | // If it is a button and has not been processed by handleText, then the child node is processed 219 | if (!isBtn && !isCompleted) { 220 | this.handleNodes(node.childNodes); 221 | } 222 | }, 223 | }; 224 | -------------------------------------------------------------------------------- /src/script/util.js: -------------------------------------------------------------------------------- 1 | // sleep function 2 | export const sleep = ms => { 3 | return new Promise(resolve => setTimeout(resolve, ms)); 4 | }; 5 | 6 | // Check if the node is in the first screen 7 | export const inViewPort = ele => { 8 | try { 9 | const rect = ele.getBoundingClientRect(); 10 | return rect.top < window.innerHeight && 11 | rect.left < window.innerWidth; 12 | 13 | } catch (e) { 14 | return true; 15 | } 16 | }; 17 | 18 | // Determine if the node has attributes 19 | export const hasAttr = (ele, attr) => { 20 | try { 21 | return ele.hasAttribute(attr); 22 | } catch (e) { 23 | return false; 24 | } 25 | }; 26 | 27 | // Set node transparency 28 | export const setOpacity = ele => { 29 | if (ele.style) { 30 | ele.style.opacity = 0; 31 | } 32 | }; 33 | 34 | // Unit conversion px -> rem 35 | export const px2rem = px => { 36 | const pxValue = typeof px === 'string' ? parseInt(px, 10) : px; 37 | const htmlElementFontSize = getComputedStyle(document.documentElement).fontSize; 38 | 39 | return `${(pxValue / parseInt(htmlElementFontSize, 10))}rem`; 40 | }; 41 | 42 | // Batch setting element properties 43 | export const setAttributes = (ele, attrs) => { 44 | Object.keys(attrs).forEach(k => ele.setAttribute(k, attrs[k])); 45 | }; 46 | 47 | // Delete element 48 | export const removeElement = ele => { 49 | const parent = ele.parentNode; 50 | if (parent) { 51 | parent.removeChild(ele); 52 | } 53 | }; 54 | 55 | // Check the element pseudo-class to return the corresponding element and width 56 | export const checkHasPseudoEle = ele => { 57 | if (!ele) return false; 58 | 59 | const beforeComputedStyle = getComputedStyle(ele, '::before'); 60 | const beforeContent = beforeComputedStyle.getPropertyValue('content'); 61 | const beforeWidth = parseFloat(beforeComputedStyle.getPropertyValue('width'), 10) || 0; 62 | const hasBefore = beforeContent && beforeContent !== 'none'; 63 | 64 | const afterComputedStyle = getComputedStyle(ele, '::after'); 65 | const afterContent = afterComputedStyle.getPropertyValue('content'); 66 | const afterWidth = parseFloat(afterComputedStyle.getPropertyValue('width'), 10) || 0; 67 | const hasAfter = afterContent && afterContent !== 'none'; 68 | 69 | const width = Math.max(beforeWidth, afterWidth); 70 | 71 | if (hasBefore || hasAfter) { 72 | return { hasBefore, hasAfter, ele, width }; 73 | } 74 | return false; 75 | }; 76 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | // sleep function 6 | const sleep = ms => { 7 | return new Promise(resolve => setTimeout(resolve, ms)); 8 | }; 9 | 10 | // Get the contents of the scr/script packaged script for injecting into the target page 11 | const genScriptContent = async function() { 12 | const sourcePath = path.resolve(__dirname, './script/dist/index.js'); 13 | const result = await promisify(fs.readFile)(sourcePath, 'utf-8'); 14 | return result; 15 | }; 16 | 17 | module.exports = { 18 | sleep, 19 | genScriptContent, 20 | }; 21 | -------------------------------------------------------------------------------- /test/lib/index.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | describe('lib/index.test.js', function() { 4 | it('works', () => { 5 | assert(true); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | --------------------------------------------------------------------------------