├── tools ├── demo │ ├── package.json │ ├── app.js │ ├── pages │ │ └── index │ │ │ ├── index.json │ │ │ ├── data.js │ │ │ ├── index.wxml │ │ │ └── index.js │ ├── app.wxss │ ├── app.json │ └── project.config.json ├── config.js ├── checkcomponents.js ├── test │ └── helper.js ├── utils.js └── build.js ├── src ├── utils │ ├── recycle-data.js │ ├── transformRpx.js │ ├── viewport-change-func.js │ └── recycle-context.js ├── recycle-item.json ├── recycle-view.json ├── recycle-item.wxml ├── recycle-view.wxss ├── recycle-item.wxss ├── recycle-item.js ├── index.js ├── index.d.ts ├── recycle-view.wxml └── recycle-view.js ├── test └── utils.js ├── images ├── js.png ├── wxml.png ├── recycle-view.png └── recycle-view.bmpr ├── .npmignore ├── .gitignore ├── .babelrc ├── gulpfile.js ├── LICENSE ├── package.json ├── .eslintrc.js └── README.md /tools/demo/package.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tools/demo/app.js: -------------------------------------------------------------------------------- 1 | App({}); 2 | -------------------------------------------------------------------------------- /src/utils/recycle-data.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../tools/test/helper') 2 | -------------------------------------------------------------------------------- /src/recycle-item.json: -------------------------------------------------------------------------------- 1 | { 2 | "component": true, 3 | "usingComponents": {} 4 | } -------------------------------------------------------------------------------- /src/recycle-view.json: -------------------------------------------------------------------------------- 1 | { 2 | "component": true, 3 | "usingComponents": {} 4 | } -------------------------------------------------------------------------------- /images/js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechat-miniprogram/recycle-view/HEAD/images/js.png -------------------------------------------------------------------------------- /images/wxml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechat-miniprogram/recycle-view/HEAD/images/wxml.png -------------------------------------------------------------------------------- /images/recycle-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechat-miniprogram/recycle-view/HEAD/images/recycle-view.png -------------------------------------------------------------------------------- /images/recycle-view.bmpr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechat-miniprogram/recycle-view/HEAD/images/recycle-view.bmpr -------------------------------------------------------------------------------- /src/recycle-item.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/recycle-view.wxss: -------------------------------------------------------------------------------- 1 | /* components/recycle-view/recycle-view.wxss */ 2 | :host { 3 | display: block; 4 | width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /src/recycle-item.wxss: -------------------------------------------------------------------------------- 1 | /* components/recycle-item/recycle-item.wxss */ 2 | :host { 3 | display: inline-block; 4 | } 5 | .wx-recycle-item { 6 | height: 100%; 7 | } -------------------------------------------------------------------------------- /tools/demo/pages/index/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "usingComponents": { 3 | "recycle-view": "../../components/recycle-view", 4 | "recycle-item": "../../components/recycle-item" 5 | } 6 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | package-lock.json 4 | 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | test 12 | tools 13 | docs 14 | miniprogram_dev 15 | node_modules 16 | coverage -------------------------------------------------------------------------------- /tools/demo/app.wxss: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | justify-content: space-between; 7 | padding: 200rpx 0; 8 | box-sizing: border-box; 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | package-lock.json 4 | yarn.lock 5 | 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | miniprogram_dist 13 | miniprogram_dev 14 | node_modules 15 | coverage -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["module-resolver", { 4 | "root": ["./src"], 5 | "alias": {} 6 | }] 7 | ], 8 | "presets": [ 9 | ["env", {"loose": true, "modules": "commonjs"}] 10 | ] 11 | } -------------------------------------------------------------------------------- /tools/demo/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages": [ 3 | "pages/index/index" 4 | ], 5 | "window": { 6 | "navigationBarBackgroundColor": "#000", 7 | "navigationBarTitleText": "WeChat", 8 | "navigationBarTextStyle": "white", 9 | "navigationStyle": "default" 10 | } 11 | } -------------------------------------------------------------------------------- /src/recycle-item.js: -------------------------------------------------------------------------------- 1 | // components/recycle-item/recycle-item.js 2 | Component({ 3 | relations: { 4 | './recycle-view': { 5 | type: 'parent', // 关联的目标节点应为子节点 6 | linked() {} 7 | } 8 | }, 9 | /** 10 | * 组件的属性列表 11 | */ 12 | properties: { 13 | }, 14 | 15 | /** 16 | * 组件的初始数据 17 | */ 18 | data: { 19 | // height: 100 20 | }, 21 | 22 | /** 23 | * 组件的方法列表 24 | */ 25 | methods: { 26 | heightChange() { 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * recycle-view组件的api使用 3 | * 提供wx.createRecycleContext进行管理功能 4 | */ 5 | const RecycleContext = require('./utils/recycle-context.js') 6 | 7 | /** 8 | * @params options参数是object对象,展开的结构如下 9 | id: recycle-view的id 10 | dataKey: recycle-item的wx:for绑定的数据变量 11 | page: recycle-view所在的页面或组件的实例 12 | itemSize: 函数或者是Object对象,生成每个recycle-item的宽和高 13 | * @return RecycleContext对象 14 | */ 15 | module.exports = function (options) { 16 | return new RecycleContext(options) 17 | } 18 | -------------------------------------------------------------------------------- /tools/demo/pages/index/data.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "response": { 3 | "data": [ 4 | { 5 | "goods": [ 6 | { 7 | "id": 0, 8 | "title": "test测试数据", 9 | "picture": '', 10 | "status": 1, 11 | "description": 'asndasdjaksdhttps://img.yzcdn.cn/u', 12 | "skus": [{ 13 | "price": 100, 14 | "promotion_info": 'dsadada', 15 | }], 16 | "unit": "zhuo", 17 | "attrs": [], 18 | "image_url": "https://img.yzcdn.cn/upload_files/2018/01/13/FioO3zv7ENB_7uqptCyHuFf6dRdU.png", 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tools/demo/project.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "项目配置文件。", 3 | "packOptions": { 4 | "ignore": [] 5 | }, 6 | "setting": { 7 | "urlCheck": true, 8 | "es6": true, 9 | "postcss": true, 10 | "minified": true, 11 | "newFeature": true, 12 | "nodeModules": true 13 | }, 14 | "compileType": "miniprogram", 15 | "libVersion": "2.7.0", 16 | "appid": "wxe0b5580bba739b69", 17 | "projectname": "recycle-view", 18 | "isGameTourist": false, 19 | "condition": { 20 | "search": { 21 | "current": -1, 22 | "list": [] 23 | }, 24 | "conversation": { 25 | "current": -1, 26 | "list": [] 27 | }, 28 | "game": { 29 | "currentL": -1, 30 | "list": [] 31 | }, 32 | "miniprogram": { 33 | "current": -1, 34 | "list": [] 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const clean = require('gulp-clean'); 3 | 4 | const config = require('./tools/config'); 5 | const BuildTask = require('./tools/build'); 6 | const id = require('./package.json').name || 'miniprogram-custom-component'; 7 | 8 | // build task instance 9 | new BuildTask(id, config.entry); 10 | 11 | // clean the generated folders and files 12 | gulp.task('clean', gulp.series(() => { 13 | return gulp.src(config.distPath, { read: false, allowEmpty: true }) 14 | .pipe(clean()) 15 | }, done => { 16 | if (config.isDev) { 17 | return gulp.src(config.demoDist, { read: false, allowEmpty: true }) 18 | .pipe(clean()); 19 | } 20 | 21 | done(); 22 | })); 23 | // watch files and build 24 | gulp.task('watch', gulp.series(`${id}-watch`)); 25 | // build for develop 26 | gulp.task('dev', gulp.series(`${id}-dev`)); 27 | // build for publish 28 | gulp.task('default', gulp.series(`${id}-default`)); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 wechat-miniprogram 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 | -------------------------------------------------------------------------------- /tools/demo/pages/index/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | after height:200px view 11 | 12 | -------------------------------------------------------------------------------- /src/utils/transformRpx.js: -------------------------------------------------------------------------------- 1 | let isIPhone = false 2 | let deviceWidth 3 | let deviceDPR 4 | const BASE_DEVICE_WIDTH = 750 5 | const checkDeviceWidth = () => { 6 | const info = wx.getSystemInfoSync() 7 | // console.log('info', info) 8 | isIPhone = info.platform === 'ios' 9 | const newDeviceWidth = info.screenWidth || 375 10 | const newDeviceDPR = info.pixelRatio || 2 11 | 12 | if (!isIPhone) { 13 | // HACK switch width and height when landscape 14 | // const newDeviceHeight = info.screenHeight || 375 15 | // 暂时不处理转屏的情况 16 | } 17 | 18 | if (newDeviceWidth !== deviceWidth || newDeviceDPR !== deviceDPR) { 19 | deviceWidth = newDeviceWidth 20 | deviceDPR = newDeviceDPR 21 | // console.info('Updated device width: ' + newDeviceWidth + 'px DPR ' + newDeviceDPR) 22 | } 23 | } 24 | checkDeviceWidth() 25 | 26 | const eps = 1e-4 27 | const transformByDPR = (number) => { 28 | if (number === 0) { 29 | return 0 30 | } 31 | number = number / BASE_DEVICE_WIDTH * deviceWidth 32 | number = Math.floor(number + eps) 33 | if (number === 0) { 34 | if (deviceDPR === 1 || !isIPhone) { 35 | return 1 36 | } 37 | return 0.5 38 | } 39 | return number 40 | } 41 | 42 | const rpxRE = /([+-]?\d+(?:\.\d+)?)rpx/gi 43 | // const inlineRpxRE = /(?::|\s|\(|\/)([+-]?\d+(?:\.\d+)?)rpx/g 44 | 45 | const transformRpx = (style, inline) => { 46 | if (typeof style !== 'string') { 47 | return style 48 | } 49 | const re = rpxRE 50 | return style.replace(re, function (match, num) { 51 | return transformByDPR(Number(num)) + (inline ? 'px' : '') 52 | }) 53 | } 54 | 55 | module.exports = { 56 | transformRpx 57 | } 58 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace recycleContext { 2 | interface itemSize { 3 | width: number; 4 | height: number; 5 | } 6 | 7 | type Component = any; 8 | type Page = any; 9 | 10 | type itemSizeFunc = (item: T, index: number) => itemSize 11 | 12 | interface options { 13 | id: string; 14 | dataKey: string; 15 | page: Component | Page; 16 | itemSize: itemSizeFunc | itemSize; 17 | useInPage?: boolean; 18 | root?: Page; 19 | } 20 | 21 | interface position { 22 | left: number; 23 | top: number; 24 | width: number; 25 | height: number; 26 | } 27 | 28 | interface RecycleContext { 29 | append(list: T[], callback?: () => void): RecycleContext 30 | appendList(list: T[], callback?: () => void): RecycleContext 31 | splice(begin: number, deleteCount: number, appendList: T[], callback?: () => void): RecycleContext; 32 | updateList(beginIndex: number, list: T[], callback?: () => void): RecycleContext 33 | update(beginIndex: number, list: T[], callback?: () => void): RecycleContext 34 | destroy(): RecycleContext 35 | forceUpdate(callback: () => void, reinitSlot: boolean): RecycleContext 36 | getBoundingClientRect(index: number | undefined): position | position[] 37 | getScrollTop(): number; 38 | transformRpx(rpx: number, addPxSuffix?: string): number; 39 | getViewportItems(inViewportPx: number): T[] 40 | getList(): T[] 41 | } 42 | } 43 | declare function createRecycleContext(op: recycleContext.options): recycleContext.RecycleContext 44 | 45 | export = createRecycleContext; -------------------------------------------------------------------------------- /src/recycle-view.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tools/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const webpack = require('webpack') 4 | const nodeExternals = require('webpack-node-externals') 5 | 6 | const isDev = process.argv.indexOf('--develop') >= 0 7 | const isWatch = process.argv.indexOf('--watch') >= 0 8 | const demoSrc = path.resolve(__dirname, './demo') 9 | const demoDist = path.resolve(__dirname, '../miniprogram_dev') 10 | const src = path.resolve(__dirname, '../src') 11 | const dev = path.join(demoDist, 'components') 12 | const dist = path.resolve(__dirname, '../miniprogram_dist') 13 | 14 | module.exports = { 15 | entry: ['index', 'recycle-item', 'recycle-view'], 16 | 17 | isDev, 18 | isWatch, 19 | srcPath: src, 20 | distPath: isDev ? dev : dist, 21 | 22 | demoSrc, 23 | demoDist, 24 | 25 | wxss: { 26 | less: false, // compile wxss with less 27 | sourcemap: false, // source map for less 28 | }, 29 | 30 | webpack: { 31 | mode: 'production', 32 | output: { 33 | filename: '[name].js', 34 | libraryTarget: 'commonjs2', 35 | }, 36 | target: 'node', 37 | externals: [nodeExternals()], // ignore node_modules 38 | module: { 39 | rules: [{ 40 | test: /\.js$/i, 41 | use: [ 42 | 'babel-loader', 43 | 'eslint-loader' 44 | ], 45 | exclude: /node_modules/ 46 | }], 47 | }, 48 | resolve: { 49 | modules: [src, 'node_modules'], 50 | extensions: ['.js', '.json'], 51 | }, 52 | plugins: [ 53 | new webpack.DefinePlugin({}), 54 | new webpack.optimize.LimitChunkCountPlugin({maxChunks: 1}), 55 | ], 56 | optimization: { 57 | minimize: false, 58 | }, 59 | // devtool: 'nosources-source-map', // source map for js 60 | performance: { 61 | hints: 'warning', 62 | assetFilter: assetFilename => assetFilename.endsWith('.js') 63 | } 64 | }, 65 | copy: ['./wxml', './wxss', './wxs', './images', './index.d.ts'], 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/viewport-change-func.js: -------------------------------------------------------------------------------- 1 | /* eslint complexity: ["error", {"max": 50}] */ 2 | const recycleData = require('./recycle-data.js') 3 | 4 | module.exports = function (e, cb) { 5 | const detail = e.detail 6 | // console.log('data change transfer use time', Date.now() - e.detail.timeStamp) 7 | let newList = [] 8 | const item = recycleData[detail.id] 9 | // 边界值判断, 避免造成异常, 假设先调用了createRecycleContext, 然后再延迟2s调用append插入数据的情况 10 | if (!item || !item.list) return 11 | const dataList = item.list 12 | const pos = detail.data 13 | const beginIndex = pos.beginIndex 14 | const endIndex = pos.endIndex 15 | item.pos = pos 16 | // 加ignoreBeginIndex和ignoreEndIndex 17 | if (typeof beginIndex === 'undefined' || beginIndex === -1 || typeof endIndex === 'undefined' || endIndex === -1) { 18 | newList = [] 19 | } else { 20 | let i = -1 21 | for (i = beginIndex; i < dataList.length && i <= endIndex; i++) { 22 | if (i >= pos.ignoreBeginIndex && i <= pos.ignoreEndIndex) continue 23 | newList.push(dataList[i]) 24 | } 25 | } 26 | const obj = { 27 | // batchSetRecycleData: !this.data.batchSetRecycleData 28 | } 29 | obj[item.key] = newList 30 | const comp = this.selectComponent('#' + detail.id) 31 | obj[comp.data.batchKey] = !this.data.batchSetRecycleData 32 | comp._setInnerBeforeAndAfterHeight({ 33 | beforeHeight: pos.minTop, 34 | afterHeight: pos.afterHeight 35 | }) 36 | this.setData(obj, () => { 37 | if (typeof cb === 'function') { 38 | cb() 39 | } 40 | }) 41 | // Fix #1 42 | // 去掉了batchSetDataKey,支持一个页面内显示2个recycle-view 43 | // const groupSetData = () => { 44 | // this.setData(obj) 45 | // comp._recycleInnerBatchDataChanged(() => { 46 | // if (typeof cb === 'function') { 47 | // cb() 48 | // } 49 | // }) 50 | // } 51 | // if (typeof this.groupSetData === 'function') { 52 | // this.groupSetData(groupSetData) 53 | // } else { 54 | // groupSetData() 55 | // } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniprogram-recycle-view", 3 | "version": "0.1.5", 4 | "description": "miniprogram custom component", 5 | "main": "miniprogram_dist/index.js", 6 | "scripts": { 7 | "dev": "rm -f miniprogram_dev/project.config.json && gulp dev --develop", 8 | "watch": "gulp watch --develop --watch", 9 | "build": "gulp", 10 | "dist": "npm run build", 11 | "clean-dev": "gulp clean --develop", 12 | "clean": "gulp clean", 13 | "test": "jest ./test/* --silent --bail", 14 | "coverage": "jest ./test/* --coverage --bail", 15 | "lint": "eslint \"src/**/*.js\"", 16 | "lint-tools": "eslint \"tools/**/*.js\" --rule \"import/no-extraneous-dependencies: false\"" 17 | }, 18 | "miniprogram": "miniprogram_dist", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/wechat-miniprogram/recycle-view.git" 22 | }, 23 | "author": "wechat-miniprogram", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "babel-core": "^6.26.3", 27 | "babel-loader": "^7.1.5", 28 | "babel-plugin-module-resolver": "^3.1.1", 29 | "babel-preset-env": "^1.7.0", 30 | "colors": "^1.3.1", 31 | "eslint": "^5.3.0", 32 | "eslint-loader": "^2.1.0", 33 | "gulp": "^4.0.0", 34 | "gulp-clean": "^0.4.0", 35 | "gulp-if": "^2.0.2", 36 | "gulp-install": "^1.1.0", 37 | "gulp-less": "^3.5.0", 38 | "gulp-rename": "^1.4.0", 39 | "gulp-sourcemaps": "^2.6.4", 40 | "j-component": "git+https://github.com/JuneAndGreen/j-component.git", 41 | "jest": "^23.5.0", 42 | "through2": "^2.0.3", 43 | "webpack": "^4.16.5", 44 | "webpack-node-externals": "^1.7.2", 45 | "eslint-config-airbnb-base": "13.1.0", 46 | "eslint-plugin-import": "^2.14.0", 47 | "eslint-plugin-node": "^7.0.1", 48 | "eslint-plugin-promise": "^3.8.0" 49 | }, 50 | "dependencies": {}, 51 | "jest": { 52 | "testEnvironment": "jsdom", 53 | "testURL": "https://jest.test", 54 | "collectCoverageFrom": [ 55 | "src/**/*.js" 56 | ], 57 | "moduleDirectories": [ 58 | "node_modules", 59 | "src" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'extends': [ 3 | 'airbnb-base', 4 | 'plugin:promise/recommended' 5 | ], 6 | 'parserOptions': { 7 | 'ecmaVersion': 9, 8 | 'ecmaFeatures': { 9 | 'jsx': false 10 | }, 11 | 'sourceType': 'module' 12 | }, 13 | 'env': { 14 | 'es6': true, 15 | 'node': true, 16 | 'jest': true 17 | }, 18 | 'plugins': [ 19 | 'import', 20 | 'node', 21 | 'promise' 22 | ], 23 | 'rules': { 24 | 'arrow-parens': 'off', 25 | 'comma-dangle': [ 26 | 'error', 27 | 'only-multiline' 28 | ], 29 | 'complexity': ['error', 10], 30 | 'func-names': 'off', 31 | 'global-require': 'off', 32 | 'handle-callback-err': [ 33 | 'error', 34 | '^(err|error)$' 35 | ], 36 | 'import/no-unresolved': [ 37 | 'error', 38 | { 39 | 'caseSensitive': true, 40 | 'commonjs': true, 41 | 'ignore': ['^[^.]'] 42 | } 43 | ], 44 | 'import/prefer-default-export': 'off', 45 | 'linebreak-style': 'off', 46 | 'no-catch-shadow': 'error', 47 | 'no-continue': 'off', 48 | 'no-div-regex': 'warn', 49 | 'no-else-return': 'off', 50 | 'no-param-reassign': 'off', 51 | 'no-plusplus': 'off', 52 | 'no-shadow': 'off', 53 | 'no-multi-assign': 'off', 54 | 'no-underscore-dangle': 'off', 55 | 'node/no-deprecated-api': 'error', 56 | 'node/process-exit-as-throw': 'error', 57 | 'object-curly-spacing': [ 58 | 'error', 59 | 'never' 60 | ], 61 | 'operator-linebreak': [ 62 | 'error', 63 | 'after', 64 | { 65 | 'overrides': { 66 | ':': 'before', 67 | '?': 'before' 68 | } 69 | } 70 | ], 71 | 'prefer-arrow-callback': 'off', 72 | 'prefer-destructuring': 'off', 73 | 'prefer-template': 'off', 74 | 'quote-props': [ 75 | 1, 76 | 'as-needed', 77 | { 78 | 'unnecessary': true 79 | } 80 | ], 81 | 'semi': [ 82 | 'error', 83 | 'never' 84 | ] 85 | }, 86 | 'globals': { 87 | 'window': true, 88 | 'document': true, 89 | 'App': true, 90 | 'Page': true, 91 | 'Component': true, 92 | 'Behavior': true, 93 | 'wx': true, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tools/checkcomponents.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const _ = require('./utils') 4 | const config = require('./config') 5 | 6 | const srcPath = config.srcPath 7 | 8 | /** 9 | * get json path's info 10 | */ 11 | function getJsonPathInfo(jsonPath) { 12 | const dirPath = path.dirname(jsonPath) 13 | const fileName = path.basename(jsonPath, '.json') 14 | const relative = path.relative(srcPath, dirPath) 15 | const fileBase = path.join(relative, fileName) 16 | 17 | return { 18 | dirPath, fileName, relative, fileBase 19 | } 20 | } 21 | 22 | /** 23 | * check included components 24 | */ 25 | const checkProps = ['usingComponents', 'componentGenerics'] 26 | async function checkIncludedComponents(jsonPath, componentListMap) { 27 | const json = _.readJson(jsonPath) 28 | if (!json) throw new Error(`json is not valid: "${jsonPath}"`) 29 | 30 | const {dirPath, fileName, fileBase} = getJsonPathInfo(jsonPath) 31 | 32 | for (let i = 0, len = checkProps.length; i < len; i++) { 33 | const checkProp = checkProps[i] 34 | const checkPropValue = json[checkProp] || {} 35 | const keys = Object.keys(checkPropValue) 36 | 37 | for (let j = 0, jlen = keys.length; j < jlen; j++) { 38 | const key = keys[j] 39 | let value = typeof checkPropValue[key] === 'object' ? checkPropValue[key].default : checkPropValue[key] 40 | if (!value) continue 41 | 42 | value = _.transformPath(value, path.sep) 43 | 44 | // check relative path 45 | const componentPath = `${path.join(dirPath, value)}.json` 46 | // eslint-disable-next-line no-await-in-loop 47 | const isExists = await _.checkFileExists(componentPath) 48 | if (isExists) { 49 | // eslint-disable-next-line no-await-in-loop 50 | await checkIncludedComponents(componentPath, componentListMap) 51 | } 52 | } 53 | } 54 | 55 | // checked 56 | componentListMap.wxmlFileList.push(`${fileBase}.wxml`) 57 | componentListMap.wxssFileList.push(`${fileBase}.wxss`) 58 | componentListMap.jsonFileList.push(`${fileBase}.json`) 59 | componentListMap.jsFileList.push(`${fileBase}.js`) 60 | 61 | componentListMap.jsFileMap[fileBase] = `${path.join(dirPath, fileName)}.js` 62 | } 63 | 64 | module.exports = async function (entry) { 65 | const componentListMap = { 66 | wxmlFileList: [], 67 | wxssFileList: [], 68 | jsonFileList: [], 69 | jsFileList: [], 70 | 71 | jsFileMap: {}, // for webpack entry 72 | } 73 | 74 | const isExists = await _.checkFileExists(entry) 75 | if (!isExists) { 76 | const {dirPath, fileName, fileBase} = getJsonPathInfo(entry) 77 | 78 | componentListMap.jsFileList.push(`${fileBase}.js`) 79 | componentListMap.jsFileMap[fileBase] = `${path.join(dirPath, fileName)}.js` 80 | 81 | return componentListMap 82 | } 83 | 84 | await checkIncludedComponents(entry, componentListMap) 85 | 86 | return componentListMap 87 | } 88 | -------------------------------------------------------------------------------- /tools/test/helper.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const jComponent = require('j-component') 4 | 5 | const config = require('../config') 6 | const _ = require('../utils') 7 | 8 | const srcPath = config.srcPath 9 | const componentMap = {} 10 | let nowLoad = null 11 | 12 | /** 13 | * register custom component 14 | */ 15 | global.Component = options => { 16 | const component = nowLoad 17 | const definition = Object.assign({ 18 | template: component.wxml, 19 | usingComponents: component.json.usingComponents, 20 | tagName: component.tagName, 21 | }, options) 22 | 23 | component.id = jComponent.register(definition) 24 | } 25 | 26 | /** 27 | * register behavior 28 | */ 29 | global.Behavior = options => jComponent.behavior(options) 30 | 31 | /** 32 | * register global components 33 | */ 34 | // eslint-disable-next-line semi-style 35 | ;[ 36 | 'view', 'scroll-view', 'swiper', 'movable-view', 'cover-view', 'cover-view', 37 | 'icon', 'text', 'rich-text', 'progress', 38 | 'button', 'checkbox', 'form', 'input', 'label', 'picker', 'picker', 'picker-view', 'radio', 'slider', 'switch', 'textarea', 39 | 'navigator', 'function-page-navigator', 40 | 'audio', 'image', 'video', 'camera', 'live-player', 'live-pusher', 41 | 'map', 42 | 'canvas', 43 | 'open-data', 'web-view', 'ad' 44 | ].forEach(name => { 45 | jComponent.register({ 46 | id: name, 47 | tagName: `wx-${name}`, 48 | template: '' 49 | }) 50 | }) 51 | 52 | /** 53 | * Touch polyfill 54 | */ 55 | class Touch { 56 | constructor(options = {}) { 57 | this.clientX = 0 58 | this.clientY = 0 59 | this.identifier = 0 60 | this.pageX = 0 61 | this.pageY = 0 62 | this.screenX = 0 63 | this.screenY = 0 64 | this.target = null 65 | 66 | Object.keys(options).forEach(key => { 67 | this[key] = options[key] 68 | }) 69 | } 70 | } 71 | global.Touch = window.Touch = Touch 72 | 73 | /** 74 | * load component 75 | */ 76 | async function load(componentPath, tagName) { 77 | if (typeof componentPath === 'object') { 78 | const definition = componentPath 79 | 80 | return jComponent.register(definition) 81 | } 82 | 83 | const wholePath = path.join(srcPath, componentPath) 84 | 85 | const oldLoad = nowLoad 86 | const component = nowLoad = {} 87 | 88 | component.tagName = tagName 89 | component.wxml = await _.readFile(`${wholePath}.wxml`) 90 | component.wxss = await _.readFile(`${wholePath}.wxss`) 91 | component.json = _.readJson(`${wholePath}.json`) 92 | 93 | if (!component.json) { 94 | throw new Error(`invalid component: ${wholePath}`) 95 | } 96 | 97 | // preload using components 98 | const usingComponents = component.json.usingComponents || {} 99 | const usingComponentKeys = Object.keys(usingComponents) 100 | for (let i = 0, len = usingComponentKeys.length; i < len; i++) { 101 | const key = usingComponentKeys[i] 102 | const usingPath = path.join(path.dirname(componentPath), usingComponents[key]) 103 | // eslint-disable-next-line no-await-in-loop 104 | const id = await load(usingPath) 105 | 106 | usingComponents[key] = id 107 | } 108 | 109 | // require js 110 | // eslint-disable-next-line import/no-dynamic-require 111 | require(wholePath) 112 | 113 | nowLoad = oldLoad 114 | componentMap[wholePath] = component 115 | 116 | return component.id 117 | } 118 | 119 | /** 120 | * render component 121 | */ 122 | function render(componentId, properties) { 123 | if (!componentId) throw new Error('you need to pass the componentId') 124 | 125 | return jComponent.create(componentId, properties) 126 | } 127 | 128 | /** 129 | * test a dom is similar to the html 130 | */ 131 | function match(dom, html) { 132 | if (!(dom instanceof window.Element) || !html || typeof html !== 'string') return false 133 | 134 | // remove some 135 | html = html.trim() 136 | .replace(/(>)[\n\r\s\t]+(<)/g, '$1$2') 137 | 138 | const a = dom.cloneNode() 139 | const b = dom.cloneNode() 140 | 141 | a.innerHTML = dom.innerHTML 142 | b.innerHTML = html 143 | 144 | return a.isEqualNode(b) 145 | } 146 | 147 | /** 148 | * wait for some time 149 | */ 150 | function sleep(time = 0) { 151 | return new Promise(resolve => { 152 | setTimeout(() => { 153 | resolve() 154 | }, time) 155 | }) 156 | } 157 | 158 | module.exports = { 159 | load, 160 | render, 161 | match, 162 | sleep, 163 | } 164 | -------------------------------------------------------------------------------- /tools/demo/pages/index/index.js: -------------------------------------------------------------------------------- 1 | //获取应用实例 2 | const app = getApp() 3 | let data = require('./data.js') 4 | let j = 1 5 | data = data.response.data 6 | const systemInfo = wx.getSystemInfoSync() 7 | 8 | // 提交wx.createRecycleContext能力 9 | const createRecycleContext = require('../../components/index.js') 10 | 11 | Page({ 12 | 13 | data: { 14 | // placeholderImage: "data:image/svg+xml,%3Csvg height='140rpx' test='132rpx' width='100%25' xmlns='http://www.w3.org/2000/svg'%3E %3Crect width='50%25' x='40' height='20%25' style='fill:rgb(204,204,204);' /%3E %3C/svg%3E" 15 | }, 16 | onLoad: function () { 17 | var ctx = createRecycleContext({ 18 | id: 'recycleId', 19 | dataKey: 'recycleList', 20 | page: this, 21 | itemSize: function(item, index) { 22 | return { 23 | width: systemInfo.windowWidth / 2, 24 | height: 160 25 | } 26 | }, 27 | placeholderClass: ['recycle-image', 'recycle-text'], 28 | // itemSize: function(item) { 29 | // return { 30 | // width: 195, 31 | // height: item.azFirst ? 130 : 120 32 | // } 33 | // }, 34 | // useInPage: true 35 | }) 36 | this.ctx = ctx; 37 | }, 38 | onUnload: function () { 39 | this.ctx.destroy() 40 | this.ctx = null 41 | }, 42 | onReady: function () { 43 | let newData = [] 44 | data.forEach((item, i) => { 45 | 46 | if (item.goods) { 47 | newData = newData.concat(item.goods) 48 | } 49 | }) 50 | this.showView() 51 | }, 52 | genData: function() { 53 | let newData = [] 54 | data.forEach((item, i) => { 55 | if (item.goods) { 56 | newData = newData.concat(item.goods) 57 | } 58 | // 构造270份数据 59 | var item = item.goods[0] 60 | for (var i = 0; i < 50; i++) { 61 | var newItem = Object.assign({}, item) 62 | newData.push(newItem) 63 | } 64 | }) 65 | const newList = [] 66 | let k = 0 67 | newData.forEach((item, i) => { 68 | item.idx = i 69 | if (k % 10 == 0) { 70 | item.azFirst = true 71 | } else { 72 | item.azFirst = false 73 | } 74 | k++ 75 | newList.push(item) 76 | item.id = item.id + (j++) 77 | item.image_url = item.image_url.replace('https', 'http') 78 | var newItem = Object.assign({}, item) 79 | if (k % 10 == 0) { 80 | newItem.azFirst = true 81 | // console.log('first item', newList.length) 82 | } 83 | k++ 84 | newItem.id = newItem.id + '_1' 85 | newItem.image_url = newItem.image_url.replace('https', 'http') 86 | newList.push(newItem) 87 | }) 88 | return newList 89 | }, 90 | showView: function () { 91 | const ctx = this.ctx 92 | const newList = this.genData() 93 | // console.log('recycle data is', newList) 94 | // API的调用方式 95 | console.log('len', newList.length) 96 | const st = Date.now() 97 | // ctx.splice(0, 0, newList, function() { 98 | // // 新增加的数据渲染完毕之后, 触发的回调 99 | // console.log('【render】use time', Date.now() - st) 100 | // }) 101 | ctx.splice(newList, () => { 102 | // 新增加的数据渲染完毕之后, 触发的回调 103 | console.log('【render】deleteList use time', Date.now() - st) 104 | // this.setData({ 105 | // scrollTop: 1000 106 | // }) 107 | }) 108 | console.log('transformRpx', ctx.transformRpx(123.5)) 109 | }, 110 | itemSizeFunc: function (item, idx) { 111 | return { 112 | width: 162, 113 | height: 182 114 | } 115 | }, 116 | onPageScroll: function() {}, // 一定要留一个空的onPageScroll函数 117 | scrollToLower: function(e) { 118 | // 延迟1s,模拟网络请求 119 | if (this.isScrollToLower) return 120 | // console.log('【【【【trigger scrollToLower') 121 | this.isScrollToLower = true 122 | setTimeout(() => { 123 | // console.log('【【【【exec scrollToLower') 124 | const newList = this.genData() 125 | this.ctx.append(newList, () => { 126 | this.isScrollToLower = false 127 | }) 128 | }, 1000) 129 | }, 130 | scrollTo2000: function (e) { 131 | this.setData({ 132 | scrollTop: 5000 133 | }) 134 | }, 135 | scrollTo0: function () { 136 | this.setData({ 137 | scrollTop: 0 138 | }) 139 | }, 140 | newEmptyPage: function() { 141 | wx.navigateTo({ 142 | url: './empty/empty' 143 | }) 144 | }, 145 | scrollToid: function() { 146 | this.setData({ 147 | index: 100 148 | }) 149 | }, 150 | getScrollTop: function() { 151 | console.log('getScrollTop', this.ctx.getScrollTop()) 152 | }, 153 | showRecycleview1: function() { 154 | this.setData({ 155 | showRecycleview: true 156 | }, () => { 157 | this.showView(); 158 | }) 159 | }, 160 | hideRecycleview: function() { 161 | this.setData({ 162 | showRecycleview: false 163 | }) 164 | } 165 | }) 166 | -------------------------------------------------------------------------------- /tools/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | // eslint-disable-next-line no-unused-vars 5 | const colors = require('colors') 6 | const through = require('through2') 7 | 8 | /** 9 | * async function wrapper 10 | */ 11 | function wrap(func, scope) { 12 | return function (...args) { 13 | if (args.length) { 14 | const temp = args.pop() 15 | if (typeof temp !== 'function') { 16 | args.push(temp) 17 | } 18 | } 19 | 20 | return new Promise(function (resolve, reject) { 21 | args.push(function (err, data) { 22 | if (err) reject(err) 23 | else resolve(data) 24 | }) 25 | 26 | func.apply((scope || null), args) 27 | }) 28 | } 29 | } 30 | 31 | const accessSync = wrap(fs.access) 32 | const statSync = wrap(fs.stat) 33 | const renameSync = wrap(fs.rename) 34 | const mkdirSync = wrap(fs.mkdir) 35 | const readFileSync = wrap(fs.readFile) 36 | const writeFileSync = wrap(fs.writeFile) 37 | 38 | /** 39 | * transform path segment separator 40 | */ 41 | function transformPath(filePath, sep = '/') { 42 | return filePath.replace(/[\\/]/g, sep) 43 | } 44 | 45 | /** 46 | * check file exists 47 | */ 48 | async function checkFileExists(filePath) { 49 | try { 50 | await accessSync(filePath) 51 | return true 52 | } catch (err) { 53 | return false 54 | } 55 | } 56 | 57 | /** 58 | * create folder 59 | */ 60 | async function recursiveMkdir(dirPath) { 61 | const prevDirPath = path.dirname(dirPath) 62 | try { 63 | await accessSync(prevDirPath) 64 | } catch (err) { 65 | // prevDirPath is not exist 66 | await recursiveMkdir(prevDirPath) 67 | } 68 | 69 | try { 70 | await accessSync(dirPath) 71 | 72 | const stat = await statSync(dirPath) 73 | if (stat && !stat.isDirectory()) { 74 | // dirPath already exists but is not a directory 75 | await renameSync(dirPath, `${dirPath}.bak`) // rename to a file with the suffix ending in '.bak' 76 | await mkdirSync(dirPath) 77 | } 78 | } catch (err) { 79 | // dirPath is not exist 80 | await mkdirSync(dirPath) 81 | } 82 | } 83 | 84 | /** 85 | * read json 86 | */ 87 | function readJson(filePath) { 88 | try { 89 | // eslint-disable-next-line import/no-dynamic-require 90 | const content = require(filePath) 91 | delete require.cache[require.resolve(filePath)] 92 | return content 93 | } catch (err) { 94 | return null 95 | } 96 | } 97 | 98 | /** 99 | * read file 100 | */ 101 | async function readFile(filePath) { 102 | try { 103 | return await readFileSync(filePath, 'utf8') 104 | } catch (err) { 105 | // eslint-disable-next-line no-console 106 | return console.error(err) 107 | } 108 | } 109 | 110 | /** 111 | * write file 112 | */ 113 | async function writeFile(filePath, data) { 114 | try { 115 | await recursiveMkdir(path.dirname(filePath)) 116 | return await writeFileSync(filePath, data, 'utf8') 117 | } catch (err) { 118 | // eslint-disable-next-line no-console 119 | return console.error(err) 120 | } 121 | } 122 | 123 | /** 124 | * time format 125 | */ 126 | function format(time, reg) { 127 | const date = typeof time === 'string' ? new Date(time) : time 128 | const map = {} 129 | map.yyyy = date.getFullYear() 130 | map.yy = ('' + map.yyyy).substr(2) 131 | map.M = date.getMonth() + 1 132 | map.MM = (map.M < 10 ? '0' : '') + map.M 133 | map.d = date.getDate() 134 | map.dd = (map.d < 10 ? '0' : '') + map.d 135 | map.H = date.getHours() 136 | map.HH = (map.H < 10 ? '0' : '') + map.H 137 | map.m = date.getMinutes() 138 | map.mm = (map.m < 10 ? '0' : '') + map.m 139 | map.s = date.getSeconds() 140 | map.ss = (map.s < 10 ? '0' : '') + map.s 141 | 142 | return reg.replace(/\byyyy|yy|MM|M|dd|d|HH|H|mm|m|ss|s\b/g, $1 => map[$1]) 143 | } 144 | 145 | /** 146 | * logger plugin 147 | */ 148 | function logger(action = 'copy') { 149 | return through.obj(function (file, enc, cb) { 150 | const type = path.extname(file.path).slice(1).toLowerCase() 151 | 152 | // eslint-disable-next-line no-console 153 | console.log(`[${format(new Date(), 'yyyy-MM-dd HH:mm:ss').grey}] [${action.green} ${type.green}] ${'=>'.cyan} ${file.path}`) 154 | 155 | this.push(file) 156 | cb() 157 | }) 158 | } 159 | 160 | /** 161 | * compare arrays 162 | */ 163 | function compareArray(arr1, arr2) { 164 | if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false 165 | if (arr1.length !== arr2.length) return false 166 | 167 | for (let i = 0, len = arr1.length; i < len; i++) { 168 | if (arr1[i] !== arr2[i]) return false 169 | } 170 | 171 | return true 172 | } 173 | 174 | /** 175 | * merge two object 176 | */ 177 | function merge(obj1, obj2) { 178 | Object.keys(obj2).forEach(key => { 179 | if (Array.isArray(obj1[key]) && Array.isArray(obj2[key])) { 180 | obj1[key] = obj1[key].concat(obj2[key]) 181 | } else if (typeof obj1[key] === 'object' && typeof obj2[key] === 'object') { 182 | obj1[key] = Object.assign(obj1[key], obj2[key]) 183 | } else { 184 | obj1[key] = obj2[key] 185 | } 186 | }) 187 | 188 | return obj1 189 | } 190 | 191 | /** 192 | * get random id 193 | */ 194 | let seed = +new Date() 195 | function getId() { 196 | return ++seed 197 | } 198 | 199 | module.exports = { 200 | wrap, 201 | transformPath, 202 | 203 | checkFileExists, 204 | readJson, 205 | readFile, 206 | writeFile, 207 | 208 | logger, 209 | format, 210 | compareArray, 211 | merge, 212 | getId, 213 | } 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # recycle-view 2 | 3 | 小程序自定义组件 4 | 5 | > 使用此组件需要依赖小程序基础库 2.2.2 版本,同时依赖开发者工具的 npm 构建。具体详情可查阅[官方 npm 文档](https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html)。 6 | 7 | ## 背景 8 | 9 | ​ 电商小程序往往需要展示很多商品,当一个页面展示很多的商品信息的时候,会造成小程序页面的卡顿以及白屏。原因有如下几点: 10 | 11 | 1. 商品列表数据很大,首次 setData 的时候耗时高 12 | 2. 渲染出来的商品列表 DOM 结构多,每次 setData 都需要创建新的虚拟树、和旧树 diff 操作耗时都比较高 13 | 3. 渲染出来的商品列表 DOM 结构多,占用的内存高,造成页面被系统回收的概率变大。 14 | 15 | 因此实现长列表组件来解决这些问题。 16 | 17 | ## 实现思路 18 | 19 | ​ 核心的思路就是只渲染显示在屏幕的数据,基本实现就是监听 scroll 事件,并且重新计算需要渲染的数据,不需要渲染的数据留一个空的 div 占位元素。 20 | 21 | ​ 假设列表数据有100个 item,知道了滚动的位置,怎么知道哪些 item 必须显示在页面?因为 item 还没渲染出来,不能通过 getComputedStyle 等 DOM 操作得到每个 item 的位置,所以无法知道哪些 item 需要渲染。为了解决这个问题,需要每个 item 固定宽高。item 的宽高的定义见下面的 API 的`createRecycleContext()`的参数 itemSize 的介绍。 22 | 23 | ​ 滚动过程中,重新渲染数据的同时,需要设置当前数据的前后的 div 占位元素高度,同时是指在同一个渲染周期内。页面渲染是通过 setData 触发的,列表数据和 div 占位高度在2个组件内进行 setData 的,为了把这2个 setData 放在同一个渲染周期,用了一个 hack 方法,所以定义 recycle-view 的 batch 属性固定为`batch="{{batchSetRecycleData}}"`。 24 | 25 | ​ 在滚动过程中,为了避免频繁出现白屏,会多渲染当前屏幕的前后2个屏幕的内容。 26 | 27 | ## 包结构 28 | 29 | 长列表组件由2个自定义组件 recycle-view、recycle-item 和一组 API 组成,对应的代码结构如下 30 | 31 | ```yaml 32 | ├── miniprogram-recycle-view/ 33 | └── recycle-view 组件 34 | └── recycle-item 组件 35 | └── index.js 36 | ``` 37 | 38 | 包结构详细描述如下: 39 | 40 | | 目录/文件 | 描述 | 41 | | ----------------- | ------------------------ | 42 | | recycle-view 组件 | 长列表组件 | 43 | | recycle-item 组件 | 长列表每一项 item 组件 | 44 | | index.js | 提供操作长列表数据的API | 45 | 46 | ## 使用方法 47 | 48 | 1. 安装组件 49 | 50 | ``` 51 | npm install --save miniprogram-recycle-view 52 | ``` 53 | 54 | 2. 在页面的 json 配置文件中添加 recycle-view 和 recycle-item 自定义组件的配置 55 | 56 | ```json 57 | { 58 | "usingComponents": { 59 | "recycle-view": "miniprogram-recycle-view/recycle-view", 60 | "recycle-item": "miniprogram-recycle-view/recycle-item" 61 | } 62 | } 63 | ``` 64 | 65 | 3. WXML 文件中引用 recycle-view 66 | 67 | ```xml 68 | 69 | 长列表前面的内容 70 | 71 | 72 | 73 | {{item.idx+1}}. {{item.title}} 74 | 75 | 76 | 长列表后面的内容 77 | 78 | ``` 79 | 80 | **recycle-view 的属性介绍如下:** 81 | 82 | | 字段名 | 类型 | 必填 | 描述 | 83 | | --------------------- | ------- | ---- | ----------------------------------------- | 84 | | id | String | 是 | id必须是页面唯一的字符串 | 85 | | batch | Boolean | 是 | 必须设置为{{batchSetRecycleData}}才能生效 | 86 | | height | Number | 否 | 设置recycle-view的高度,默认为页面高度 | 87 | | width | Number | 否 | 设置recycle-view的宽度,默认是页面的宽度 | 88 | | enable-back-to-top | Boolean | 否 | 默认为false,同scroll-view同名字段 | 89 | | scroll-top | Number | 否 | 默认为false,同scroll-view同名字段 | 90 | | scroll-y | Number | 否 | 默认为true,同scroll-view同名字段 | 91 | | scroll-to-index | Number | 否 | 设置滚动到长列表的项 | 92 | | placeholder-image | String | 否 | 默认占位背景图片,在渲染不及时的时候显示,不建议使用大图作为占位。建议传入SVG的Base64格式,可使用[工具](https://codepen.io/jakob-e/pen/doMoML)将SVG代码转为Base64格式。支持SVG中设置rpx。 | 93 | | scroll-with-animation | Boolean | 否 | 默认为false,同scroll-view的同名字段 | 94 | | lower-threshold | Number | 否 | 默认为false,同scroll-view同名字段 | 95 | | upper-threshold | Number | 否 | 默认为false,同scroll-view同名字段 | 96 | | bindscroll | 事件 | 否 | 同scroll-view同名字段 | 97 | | bindscrolltolower | 事件 | 否 | 同scroll-view同名字段 | 98 | | bindscrolltoupper | 事件 | 否 | 同scroll-view同名字段 | 99 | 100 | **recycle-view 包含3个 slot,具体介绍如下:** 101 | 102 | | 名称 | 描述 | 103 | | --------- | --------------------------------------------------------- | 104 | | before | 默认 slot 的前面的非回收区域 | 105 | | 默认 slot | 长列表的列表展示区域,recycle-item 必须定义在默认 slot 中 | 106 | | after | 默认 slot 的后面的非回收区域 | 107 | 108 | ​ 长列表的内容实际是在一个 scroll-view 滚动区域里面的,当长列表里面的内容,不止是单独的一个列表的时候,例如我们页面底部都会有一个 copyright 的声明,我们就可以把这部分的内容放在 before 和 after 这2个 slot 里面。 109 | 110 | **recycle-item 的介绍如下:** 111 | 112 | ​ 需要注意的是,recycle-item 中必须定义 wx:for 列表循环,不应该通过 setData 来设置 wx:for 绑定的变量,而是通过`createRecycleContext`方法创建`RecycleContext`对象来管理数据,`createRecycleContext`在 index.js 文件里面定义。建议同时设置 wx:key,以提升列表的渲染性能。 113 | 114 | 4. 页面 JS 管理 recycle-view 的数据 115 | 116 | ```javascript 117 | const createRecycleContext = require('miniprogram-recycle-view') 118 | Page({ 119 | onReady: function() { 120 | var ctx = createRecycleContext({ 121 | id: 'recycleId', 122 | dataKey: 'recycleList', 123 | page: this, 124 | itemSize: { // 这个参数也可以直接传下面定义的this.itemSizeFunc函数 125 | width: 162, 126 | height: 182 127 | } 128 | }) 129 | ctx.append(newList) 130 | // ctx.update(beginIndex, list) 131 | // ctx.destroy() 132 | }, 133 | itemSizeFunc: function (item, idx) { 134 | return { 135 | width: 162, 136 | height: 182 137 | } 138 | } 139 | }) 140 | ``` 141 | 142 | `typescript`支持,使用如下方式引入 143 | ```typescript 144 | import * as createRecycleContext from 'miniprogram-recycle-view'; 145 | ``` 146 | 147 | ​ 页面必须通过 Component 构造器定义,页面引入了`miniprogram-recycle-view`包之后,会在 wx 对象下面新增接口`createRecycleContext`函数创建`RecycleContext`对象来管理 recycle-view 定义的的数据,`createRecycleContext`接收类型为1个 Object 的参数,Object 参数的每一个 key 的介绍如下: 148 | 149 | | 参数名 | 类型 | 描述 | 150 | | -------- | --------------- | --------------------------------------------------------------- | 151 | | id | String | 对应 recycle-view 的 id 属性的值 | 152 | | dataKey | String | 对应 recycle-item 的 wx:for 属性设置的绑定变量名 | 153 | | page | Page/Component | recycle-view 所在的页面或者组件的实例,页面或者组件内可以直接传 this | 154 | | itemSize | Object/Function | 此参数用来生成recycle-item的宽和高,前面提到过,要知道当前需要渲染哪些item,必须知道item的宽高才能进行计算
Object必须包含{width, height}两个属性,Function的话接收item, index这2个参数,返回一个包含{width, height}的Object
itemSize如果是函数,函数里面`this`指向RecycleContext
如果样式使用了rpx,可以通过transformRpx来转化为px。
为Object类型的时候,还有另外一种用法,详细情况见下面的itemSize章节的介绍。 | 155 | | useInPage | Boolean | 是否整个页面只有recycle-view。Page的定义里面必须至少加空的onPageScroll函数,主要是用在页面级别的长列表,并且需要用到onPullDownRefresh的效果。切必须设置`root`参数为当前页面对象 | 156 | | root | Page | 当前页面对象,可以通过getCurrentPages获取, 当useInPage为true必须提供 | 157 | 158 | RecycleContext 对象提供的方法有: 159 | 160 | | 方法 | 参数 | 说明 | 161 | | --------------------- | ---------------------------- | ------------------------------------------------------------ | 162 | | append | list, callback | 在当前的长列表数据上追加list数据,callback是渲染完成的回调函数 | 163 | | splice | begin, count, list, callback | 插入/删除长列表数据,参数同Array的[splice](http://www.w3school.com.cn/js/jsref_splice.asp)函数,callback是渲染完成的回调函数 | 164 | | update | begin, list, callback | 更新长列表的数据,从索引参数begin开始,更新为参数list,参数callback同splice。 | 165 | | destroy | 无 | 销毁RecycleContext对象,在recycle-view销毁的时候调用此方法 | 166 | | forceUpdate | callback, reinitSlot | 重新渲染recycle-view。callback是渲染完成的回调函数,当before和after这2个slot的高度发生变化时候调用此函数,reinitSlot设置为true。当item的宽高发生变化的时候也需要调用此方法。 | 167 | | getBoundingClientRect | index | 获取某个数据项的在长列表中的位置,返回{left, top, width, height}的Object。 | 168 | | getScrollTop | 无 | 获取长列表的当前的滚动位置。 | 169 | | transformRpx | rpx | 将rpx转化为px,返回转化后的px整数。itemSize返回的宽高单位是px,可以在这里调用此函数将rpx转化为px,参数是Number,例如ctx.transformRpx(140),返回70。注意,transformRpx会进行四舍五入,所以transformRpx(20) + transformRpx(90)不一定等于transformRpx(110) | 170 | | getViewportItems | inViewportPx | 获取在视窗内的数据项,用于判断某个项是否出现在视窗内。用于曝光数据上报,菜品和类别的联动效果实现。参数inViewportPx表示距离屏幕多少像素为出现在屏幕内,可以为负值。 | 171 | | getList | 无 | 获取到完整的数据列表 | 172 | 173 | ## itemSize使用 174 | 175 | itemSize可以为包含{width, height}的Object,所有数据只有一种宽高信息。如果有多种,则可以提供一个函数,长列表组件会调用这个函数生成每条数据的宽高信息,如下所示: 176 | 177 | ```javascript 178 | function(item, index) { 179 | return { 180 | width: 195, 181 | height: item.azFirst ? 130 : 120 182 | } 183 | } 184 | ``` 185 | 186 | 187 | 188 | ## Tips 189 | 190 | 1. recycle-view设置batch属性的值必须为{{batchSetRecycleData}}。 191 | 2. recycle-item的宽高必须和itemSize设置的宽高一致,否则会出现跳动的bug。 192 | 3. recycle-view设置的高度必须和其style里面设置的样式一致。 193 | 4. `createRecycleContext(options)`的id参数必须和recycle-view的id属性一致,dataKey参数必须和recycle-item的wx:for绑定的变量名一致。 194 | 5. 不能在recycle-item里面使用wx:for的index变量作为索引值的,请使用{{item.\_\_index\_\_}}替代。 195 | 6. 不要通过setData设置recycle-item的wx:for的变量值,建议recycle-item设置wx:key属性。 196 | 7. 如果长列表里面包含图片,必须保证图片资源是有HTTP缓存的,否则在滚动过程中会发起很多的图片请求。 197 | 8. 有些数据不一定会渲染出来,使用wx.createSelectorQuery的时候有可能会失效,可使用RecycleContext的getBoundingClientRect来替代。 198 | 9. 当使用了useInPage参数的时候,必须在Page里面定义onPageScroll事件。 199 | 10. transformRpx会进行四舍五入,所以`transformRpx(20) + transformRpx(90)`不一定等于`transformRpx(110)` 200 | 11. 如果一个页面有多个长列表,必须多设置batch-key属性,每个的batch-key的值和batch属性的变量必须不一致。例如 201 | ```html 202 | 203 | 204 | ``` -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const gulp = require('gulp') 4 | const clean = require('gulp-clean') 5 | const less = require('gulp-less') 6 | const rename = require('gulp-rename') 7 | const gulpif = require('gulp-if') 8 | const sourcemaps = require('gulp-sourcemaps') 9 | const webpack = require('webpack') 10 | const gulpInstall = require('gulp-install') 11 | 12 | const config = require('./config') 13 | const checkComponents = require('./checkcomponents') 14 | const _ = require('./utils') 15 | 16 | const wxssConfig = config.wxss || {} 17 | const srcPath = config.srcPath 18 | const distPath = config.distPath 19 | 20 | /** 21 | * get wxss stream 22 | */ 23 | function wxss(wxssFileList) { 24 | if (!wxssFileList.length) return false 25 | 26 | return gulp.src(wxssFileList, {cwd: srcPath, base: srcPath}) 27 | .pipe(gulpif(wxssConfig.less && wxssConfig.sourcemap, sourcemaps.init())) 28 | .pipe(gulpif(wxssConfig.less, less({paths: [srcPath]}))) 29 | .pipe(rename({extname: '.wxss'})) 30 | .pipe(gulpif(wxssConfig.less && wxssConfig.sourcemap, sourcemaps.write('./'))) 31 | .pipe(_.logger(wxssConfig.less ? 'generate' : undefined)) 32 | .pipe(gulp.dest(distPath)) 33 | } 34 | 35 | /** 36 | * get js stream 37 | */ 38 | function js(jsFileMap, scope) { 39 | const webpackConfig = config.webpack 40 | const webpackCallback = (err, stats) => { 41 | if (!err) { 42 | // eslint-disable-next-line no-console 43 | console.log(stats.toString({ 44 | assets: true, 45 | cached: false, 46 | colors: true, 47 | children: false, 48 | errors: true, 49 | warnings: true, 50 | version: true, 51 | modules: false, 52 | publicPath: true, 53 | })) 54 | } else { 55 | // eslint-disable-next-line no-console 56 | console.log(err) 57 | } 58 | } 59 | 60 | webpackConfig.entry = jsFileMap 61 | webpackConfig.output.path = distPath 62 | 63 | if (scope.webpackWatcher) { 64 | scope.webpackWatcher.close() 65 | scope.webpackWatcher = null 66 | } 67 | 68 | if (config.isWatch) { 69 | scope.webpackWatcher = webpack(webpackConfig).watch({ 70 | ignored: /node_modules/, 71 | }, webpackCallback) 72 | } else { 73 | webpack(webpackConfig).run(webpackCallback) 74 | } 75 | } 76 | 77 | /** 78 | * copy file 79 | */ 80 | function copy(copyFileList) { 81 | if (!copyFileList.length) return false 82 | 83 | return gulp.src(copyFileList, {cwd: srcPath, base: srcPath}) 84 | .pipe(_.logger()) 85 | .pipe(gulp.dest(distPath)) 86 | } 87 | 88 | /** 89 | * install packages 90 | */ 91 | function install() { 92 | return gulp.series(async () => { 93 | const demoDist = config.demoDist 94 | const demoPackageJsonPath = path.join(demoDist, 'package.json') 95 | const packageJson = _.readJson(path.resolve(__dirname, '../package.json')) 96 | const dependencies = packageJson.dependencies || {} 97 | 98 | await _.writeFile(demoPackageJsonPath, JSON.stringify({dependencies}, null, '\t')) // write dev demo's package.json 99 | }, () => { 100 | const demoDist = config.demoDist 101 | const demoPackageJsonPath = path.join(demoDist, 'package.json') 102 | 103 | return gulp.src(demoPackageJsonPath) 104 | .pipe(gulpInstall({production: true})) 105 | }) 106 | } 107 | 108 | class BuildTask { 109 | constructor(id, entry) { 110 | if (!entry) return 111 | 112 | this.id = id 113 | this.entries = Array.isArray(config.entry) ? config.entry : [config.entry] 114 | this.copyList = Array.isArray(config.copy) ? config.copy : [] 115 | this.componentListMap = {} 116 | this.cachedComponentListMap = {} 117 | 118 | this.init() 119 | } 120 | 121 | init() { 122 | const id = this.id 123 | 124 | /** 125 | * clean the dist folder 126 | */ 127 | gulp.task(`${id}-clean-dist`, () => gulp.src(distPath, {read: false, allowEmpty: true}).pipe(clean())) 128 | 129 | /** 130 | * copy demo to the dev folder 131 | */ 132 | let isDemoExists = false 133 | gulp.task(`${id}-demo`, gulp.series(async () => { 134 | const demoDist = config.demoDist 135 | 136 | isDemoExists = await _.checkFileExists(path.join(demoDist, 'project.config.json')) 137 | }, done => { 138 | if (!isDemoExists) { 139 | const demoSrc = config.demoSrc 140 | const demoDist = config.demoDist 141 | 142 | return gulp.src('**/*', {cwd: demoSrc, base: demoSrc}) 143 | .pipe(gulp.dest(demoDist)) 144 | } 145 | 146 | return done() 147 | })) 148 | 149 | /** 150 | * install packages for dev 151 | */ 152 | gulp.task(`${id}-install`, install()) 153 | 154 | /** 155 | * check custom components 156 | */ 157 | gulp.task(`${id}-component-check`, async () => { 158 | const entries = this.entries 159 | const mergeComponentListMap = {} 160 | for (let i = 0, len = entries.length; i < len; i++) { 161 | let entry = entries[i] 162 | entry = path.join(srcPath, `${entry}.json`) 163 | // eslint-disable-next-line no-await-in-loop 164 | const newComponentListMap = await checkComponents(entry) 165 | 166 | _.merge(mergeComponentListMap, newComponentListMap) 167 | } 168 | 169 | this.cachedComponentListMap = this.componentListMap 170 | this.componentListMap = mergeComponentListMap 171 | }) 172 | 173 | /** 174 | * write json to the dist folder 175 | */ 176 | gulp.task(`${id}-component-json`, done => { 177 | const jsonFileList = this.componentListMap.jsonFileList 178 | 179 | if (jsonFileList && jsonFileList.length) { 180 | return copy(this.componentListMap.jsonFileList) 181 | } 182 | 183 | return done() 184 | }) 185 | 186 | /** 187 | * copy wxml to the dist folder 188 | */ 189 | gulp.task(`${id}-component-wxml`, done => { 190 | const wxmlFileList = this.componentListMap.wxmlFileList 191 | 192 | if (wxmlFileList && 193 | wxmlFileList.length && 194 | !_.compareArray(this.cachedComponentListMap.wxmlFileList, wxmlFileList)) { 195 | return copy(wxmlFileList) 196 | } 197 | 198 | return done() 199 | }) 200 | 201 | /** 202 | * generate wxss to the dist folder 203 | */ 204 | gulp.task(`${id}-component-wxss`, done => { 205 | const wxssFileList = this.componentListMap.wxssFileList 206 | 207 | if (wxssFileList && 208 | wxssFileList.length && 209 | !_.compareArray(this.cachedComponentListMap.wxssFileList, wxssFileList)) { 210 | return wxss(wxssFileList, srcPath, distPath) 211 | } 212 | 213 | return done() 214 | }) 215 | 216 | /** 217 | * generate js to the dist folder 218 | */ 219 | gulp.task(`${id}-component-js`, done => { 220 | const jsFileList = this.componentListMap.jsFileList 221 | 222 | if (jsFileList && 223 | jsFileList.length && 224 | !_.compareArray(this.cachedComponentListMap.jsFileList, jsFileList)) { 225 | js(this.componentListMap.jsFileMap, this) 226 | } 227 | 228 | return done() 229 | }) 230 | 231 | /** 232 | * copy resources to dist folder 233 | */ 234 | gulp.task(`${id}-copy`, gulp.parallel(async done => { 235 | const copyList = this.copyList 236 | const anAsyncFunction = async dir => { 237 | const isFileExist = await _.checkFileExists(path.join(srcPath, dir)); 238 | if (!isFileExist){ 239 | return path.join(dir, '**/*.!(wxss)') 240 | } 241 | return dir 242 | } 243 | const copyFileList = await Promise.all(copyList.map(item => anAsyncFunction(item))) 244 | if (copyFileList.length) return copy(copyFileList) 245 | 246 | return done() 247 | }, done => { 248 | const copyList = this.copyList 249 | const copyFileList = copyList.map(dir => path.join(dir, '**/*.wxss')) 250 | 251 | if (copyFileList.length) return wxss(copyFileList, srcPath, distPath) 252 | 253 | return done() 254 | })) 255 | 256 | /** 257 | * watch json 258 | */ 259 | gulp.task(`${id}-watch-json`, () => gulp.watch(this.componentListMap.jsonFileList, {cwd: srcPath, base: srcPath}, gulp.series(`${id}-component-check`, gulp.parallel(`${id}-component-wxml`, `${id}-component-wxss`, `${id}-component-js`, `${id}-component-json`)))) 260 | 261 | /** 262 | * watch wxml 263 | */ 264 | gulp.task(`${id}-watch-wxml`, () => { 265 | this.cachedComponentListMap.wxmlFileList = null 266 | return gulp.watch(this.componentListMap.wxmlFileList, {cwd: srcPath, base: srcPath}, gulp.series(`${id}-component-wxml`)) 267 | }) 268 | 269 | /** 270 | * watch wxss 271 | */ 272 | gulp.task(`${id}-watch-wxss`, () => { 273 | this.cachedComponentListMap.wxssFileList = null 274 | return gulp.watch('**/*.wxss', {cwd: srcPath, base: srcPath}, gulp.series(`${id}-component-wxss`)) 275 | }) 276 | 277 | /** 278 | * watch resources 279 | */ 280 | gulp.task(`${id}-watch-copy`, () => { 281 | const copyList = this.copyList 282 | const copyFileList = copyList.map(dir => path.join(dir, '**/*')) 283 | const watchCallback = filePath => copy([filePath]) 284 | 285 | return gulp.watch(copyFileList, {cwd: srcPath, base: srcPath}) 286 | .on('change', watchCallback) 287 | .on('add', watchCallback) 288 | .on('unlink', watchCallback) 289 | }) 290 | 291 | /** 292 | * watch demo 293 | */ 294 | gulp.task(`${id}-watch-demo`, () => { 295 | const demoSrc = config.demoSrc 296 | const demoDist = config.demoDist 297 | const watchCallback = filePath => gulp.src(filePath, {cwd: demoSrc, base: demoSrc}) 298 | .pipe(gulp.dest(demoDist)) 299 | 300 | return gulp.watch('**/*', {cwd: demoSrc, base: demoSrc}) 301 | .on('change', watchCallback) 302 | .on('add', watchCallback) 303 | .on('unlink', watchCallback) 304 | }) 305 | 306 | /** 307 | * watch installed packages 308 | */ 309 | gulp.task(`${id}-watch-install`, () => gulp.watch(path.resolve(__dirname, '../package.json'), install())) 310 | 311 | /** 312 | * build custom component 313 | */ 314 | gulp.task(`${id}-build`, gulp.series(`${id}-clean-dist`, `${id}-component-check`, gulp.parallel(`${id}-component-wxml`, `${id}-component-wxss`, `${id}-component-js`, `${id}-component-json`, `${id}-copy`))) 315 | 316 | gulp.task(`${id}-watch`, gulp.series(`${id}-build`, `${id}-demo`, `${id}-install`, gulp.parallel(`${id}-watch-wxml`, `${id}-watch-wxss`, `${id}-watch-json`, `${id}-watch-copy`, `${id}-watch-install`, `${id}-watch-demo`))) 317 | 318 | gulp.task(`${id}-dev`, gulp.series(`${id}-build`, `${id}-demo`, `${id}-install`)) 319 | 320 | gulp.task(`${id}-default`, gulp.series(`${id}-build`)) 321 | } 322 | } 323 | 324 | module.exports = BuildTask 325 | -------------------------------------------------------------------------------- /src/utils/recycle-context.js: -------------------------------------------------------------------------------- 1 | /* eslint complexity: ["error", {"max": 50}] */ 2 | const recycleData = require('./recycle-data.js') 3 | const recycleViewportChangeFunc = require('./viewport-change-func') 4 | const transformRpx = require('./transformRpx.js') 5 | 6 | const RECT_SIZE = 200 7 | 8 | // eslint-disable-next-line no-complexity 9 | function RecycleContext({ 10 | id, dataKey, page, itemSize, useInPage, placeholderClass, root 11 | }) { 12 | if (!id || !dataKey || !page || !itemSize) { 13 | throw new Error('parameter id, dataKey, page, itemSize is required') 14 | } 15 | if (typeof itemSize !== 'function' && typeof itemSize !== 'object') { 16 | throw new Error('parameter itemSize must be function or object with key width and height') 17 | } 18 | if (typeof itemSize === 'object' && (!itemSize.width || !itemSize.height) && 19 | (!itemSize.props || !itemSize.queryClass || !itemSize.dataKey)) { 20 | throw new Error('parameter itemSize must be function or object with key width and height') 21 | } 22 | this.id = id 23 | this.dataKey = dataKey 24 | this.page = page 25 | // 加root参数给useInPage单独使用 26 | this.root = root 27 | this.placeholderClass = placeholderClass 28 | page._recycleViewportChange = recycleViewportChangeFunc 29 | this.comp = page.selectComponent('#' + id) 30 | this.itemSize = itemSize 31 | this.itemSizeOpt = itemSize 32 | // if (!this.comp) { 33 | // throw ` with id ${id} not found` 34 | // } 35 | this.useInPage = useInPage || false 36 | if (this.comp) { 37 | this.comp.context = this 38 | this.comp.setPage(page) 39 | this.comp.setUseInPage(this.useInPage) 40 | } 41 | if (this.useInPage && !this.root) { 42 | throw new Error('parameter root is required when useInPage is true') 43 | } 44 | if (this.useInPage) { 45 | this.oldPageScroll = this.root.onPageScroll 46 | // 重写onPageScroll事件 47 | this.root.onPageScroll = (e) => { 48 | // this.checkComp(); 49 | if (this.comp) { 50 | this.comp._scrollViewDidScroll({ 51 | detail: { 52 | scrollLeft: 0, 53 | scrollTop: e.scrollTop 54 | } 55 | }) 56 | } 57 | this.oldPageScroll.apply(this.root, [e]) 58 | } 59 | this.oldReachBottom = this.root.onReachBottom 60 | this.root.onReachBottom = (e) => { 61 | if (this.comp) { 62 | this.comp.triggerEvent('scrolltolower', {}) 63 | } 64 | this.oldReachBottom.apply(this.root, [e]) 65 | } 66 | this.oldPullDownRefresh = this.root.onPullDownRefresh 67 | this.root.onPullDownRefresh = (e) => { 68 | if (this.comp) { 69 | this.comp.triggerEvent('scrolltoupper', {}) 70 | } 71 | this.oldPullDownRefresh.apply(this.root, [e]) 72 | } 73 | } 74 | } 75 | RecycleContext.prototype.checkComp = function () { 76 | if (!this.comp) { 77 | this.comp = this.page.selectComponent('#' + this.id) 78 | if (this.comp) { 79 | this.comp.setUseInPage(this.useInPage) 80 | this.comp.context = this 81 | this.comp.setPage(this.page) 82 | } else { 83 | throw new Error('the recycle-view correspond to this context is detached, pls create another RecycleContext') 84 | } 85 | } 86 | } 87 | RecycleContext.prototype.appendList = function (list, cb) { 88 | this.checkComp() 89 | const id = this.id 90 | const dataKey = this.dataKey 91 | if (!recycleData[id]) { 92 | recycleData[id] = { 93 | key: dataKey, 94 | id, 95 | list, 96 | sizeMap: {}, 97 | sizeArray: [] 98 | } 99 | } else { 100 | recycleData[id].dataKey = dataKey 101 | recycleData[id].list = recycleData[id].list.concat(list) 102 | } 103 | this._forceRerender(id, cb) 104 | return this 105 | } 106 | RecycleContext.prototype._forceRerender = function (id, cb) { 107 | this.isDataReady = true // 首次调用说明数据已经ready了 108 | // 动态计算高度并缓存 109 | const that = this 110 | let allrect = null 111 | let parentRect = null 112 | let count = 0 113 | 114 | function setPlaceholderImage() { 115 | if (!allrect || !parentRect) return 116 | const svgRects = [] 117 | for (let i = 0; i < count; i++) { 118 | svgRects.push({ 119 | left: allrect[i].left - parentRect.left, 120 | top: allrect[i].top - parentRect.top, 121 | width: allrect[i].width, 122 | height: allrect[i].height 123 | }) 124 | } 125 | that.comp.setPlaceholderImage(svgRects, { 126 | width: parentRect.width, 127 | height: parentRect.height 128 | }) 129 | } 130 | function newcb() { 131 | if (cb) { 132 | cb() 133 | } 134 | // 计算placeholder, 只有在动态计算高度的时候才支持 135 | if (that.autoCalculateSize && that.placeholderClass) { 136 | const newQueryClass = [] 137 | that.placeholderClass.forEach(item => { 138 | newQueryClass.push(`.${that.itemSizeOpt.queryClass} .` + item) 139 | }) 140 | // newQueryClass.push(`.` + that.itemSizeOpt.queryClass) 141 | count = newQueryClass.length 142 | wx.createSelectorQuery().selectAll(newQueryClass.join(',')).boundingClientRect(rect => { 143 | if (rect.length < count) return 144 | allrect = rect 145 | setPlaceholderImage() 146 | }).exec() 147 | wx.createSelectorQuery().select('.' + that.itemSizeOpt.queryClass).boundingClientRect(rect => { 148 | parentRect = rect 149 | setPlaceholderImage() 150 | }).exec() 151 | } 152 | } 153 | if (Object.prototype.toString.call(this.itemSizeOpt) === '[object Object]' && 154 | this.itemSizeOpt && !this.itemSizeOpt.width) { 155 | this._recalculateSizeByProp(recycleData[id].list, function (sizeData) { 156 | recycleData[id].sizeMap = sizeData.map 157 | recycleData[id].sizeArray = sizeData.array 158 | // 触发强制渲染 159 | that.comp.forceUpdate(newcb) 160 | }) 161 | return 162 | } 163 | const sizeData = this._recalculateSize(recycleData[id].list) 164 | recycleData[id].sizeMap = sizeData.map 165 | // console.log('size is', sizeData.array, sizeData.map, 'totalHeight', sizeData.totalHeight) 166 | // console.log('sizeArray', sizeData.array) 167 | recycleData[id].sizeArray = sizeData.array 168 | // 触发强制渲染 169 | this.comp.forceUpdate(cb) 170 | } 171 | function getValue(item, key) { 172 | if (!key) return item 173 | if (typeof item[key] !== 'undefined') return item[key] 174 | const keyItems = key.split('.') 175 | for (let i = 0; i < keyItems.length; i++) { 176 | item = item[keyItems[i]] 177 | if (typeof item === 'undefined' || (typeof item === 'object' && !item)) { 178 | return undefined 179 | } 180 | } 181 | return item 182 | } 183 | function getValues(item, keys) { 184 | if (Object.prototype.toString.call(keys) !== '[object Array]') { 185 | keys = [keys] 186 | } 187 | const vals = {} 188 | for (let i = 0; i < keys.length; i++) { 189 | vals[keys[i]] = getValue(item, keys[i]) 190 | } 191 | return vals 192 | } 193 | function isArray(arr) { 194 | return Object.prototype.toString.call(arr) === '[object Array]' 195 | } 196 | function isSamePureValue(item1, item2) { 197 | if (typeof item1 !== typeof item2) return false 198 | if (isArray(item1) && isArray(item2)) { 199 | if (item1.length !== item2.length) return false 200 | for (let i = 0; i < item1.length; i++) { 201 | if (item1[i] !== item2[i]) return false 202 | } 203 | return true 204 | } 205 | return item1 === item2 206 | } 207 | function isSameValue(item1, item2, keys) { 208 | if (!isArray(keys)) { 209 | keys = [keys] 210 | } 211 | for (let i = 0; i < keys.length; i++) { 212 | if (!isSamePureValue(getValue(item1, keys[i]), getValue(item2, keys[i]))) return false 213 | } 214 | return true 215 | } 216 | RecycleContext.prototype._recalculateSizeByProp = function (list, cb) { 217 | const itemSize = this.itemSizeOpt 218 | let propValueMap = this.propValueMap || [] 219 | const calcNewItems = [] 220 | const needCalcPropIndex = [] 221 | if (itemSize.cacheKey) { 222 | propValueMap = wx.getStorageSync(itemSize.cacheKey) || [] 223 | // eslint-disable-next-line no-console 224 | // console.log('[recycle-view] get itemSize from cache', propValueMap) 225 | } 226 | this.autoCalculateSize = true 227 | const item2PropValueMap = [] 228 | for (let i = 0; i < list.length; i++) { 229 | let item2PropValueIndex = propValueMap.length 230 | if (!propValueMap.length) { 231 | const val = getValues(list[i], itemSize.props) 232 | val.__index__ = i 233 | propValueMap.push(val) 234 | calcNewItems.push(list[i]) 235 | needCalcPropIndex.push(item2PropValueIndex) 236 | item2PropValueMap.push({ 237 | index: i, 238 | sizeIndex: item2PropValueIndex 239 | }) 240 | continue 241 | } 242 | let found = false 243 | for (let j = 0; j < propValueMap.length; j++) { 244 | if (isSameValue(propValueMap[j], list[i], itemSize.props)) { 245 | item2PropValueIndex = j 246 | found = true 247 | break 248 | } 249 | } 250 | if (!found) { 251 | const val = getValues(list[i], itemSize.props) 252 | val.__index__ = i 253 | propValueMap.push(val) 254 | calcNewItems.push(list[i]) 255 | needCalcPropIndex.push(item2PropValueIndex) 256 | } 257 | item2PropValueMap.push({ 258 | index: i, 259 | sizeIndex: item2PropValueIndex 260 | }) 261 | } 262 | // this.item2PropValueMap = item2PropValueMap 263 | this.propValueMap = propValueMap 264 | if (propValueMap.length > 10) { 265 | // eslint-disable-next-line no-console 266 | console.warn('[recycle-view] get itemSize count exceed maximum of 10, now got', propValueMap) 267 | } 268 | // console.log('itemsize', propValueMap, item2PropValueMap) 269 | // 预先渲染 270 | const that = this 271 | function newItemSize(item, index) { 272 | const sizeIndex = item2PropValueMap[index] 273 | if (!sizeIndex) { 274 | // eslint-disable-next-line no-console 275 | console.error('[recycle-view] auto calculate size array error, no map size found', item, index, item2PropValueMap) 276 | throw new Error('[recycle-view] auto calculate size array error, no map size found') 277 | } 278 | const size = propValueMap[sizeIndex.sizeIndex] 279 | if (!size) { 280 | // eslint-disable-next-line no-console 281 | console.log('[recycle-view] auto calculate size array error, no size found', item, index, sizeIndex, propValueMap) 282 | throw new Error('[recycle-view] auto calculate size array error, no size found') 283 | } 284 | return { 285 | width: size.width, 286 | height: size.height 287 | } 288 | } 289 | function sizeReady(rects) { 290 | rects.forEach((rect, index) => { 291 | const propValueIndex = needCalcPropIndex[index] 292 | propValueMap[propValueIndex].width = rect.width 293 | propValueMap[propValueIndex].height = rect.height 294 | }) 295 | that.itemSize = newItemSize 296 | const sizeData = that._recalculateSize(list) 297 | if (itemSize.cacheKey) { 298 | wx.setStorageSync(itemSize.cacheKey, propValueMap) // 把数据缓存起来 299 | } 300 | if (cb) { 301 | cb(sizeData) 302 | } 303 | } 304 | if (calcNewItems.length) { 305 | const obj = {} 306 | obj[itemSize.dataKey] = calcNewItems 307 | this.page.setData(obj, () => { 308 | // wx.createSelectorQuery().select(itemSize.componentClass).boundingClientRect(rects => { 309 | // compSize = rects; 310 | // if (compSize && allItemSize) { 311 | // sizeReady(); 312 | // } 313 | // }).exec(); 314 | wx.createSelectorQuery().selectAll('.' + itemSize.queryClass).boundingClientRect((rects) => { 315 | sizeReady(rects) 316 | }).exec() 317 | }) 318 | } else { 319 | that.itemSize = newItemSize 320 | const sizeData = that._recalculateSize(list) 321 | if (cb) { 322 | cb(sizeData) 323 | } 324 | } 325 | } 326 | // 当before和after这2个slot发生变化的时候调用一下此接口 327 | RecycleContext.prototype._recalculateSize = function (list) { 328 | // 遍历所有的数据 329 | // 应该最多就千量级的, 遍历没有问题 330 | const sizeMap = {} 331 | const func = this.itemSize 332 | const funcExist = typeof func === 'function' 333 | const comp = this.comp 334 | const compData = comp.data 335 | let offsetLeft = 0 336 | let offsetTop = 0 337 | let line = 0 338 | let column = 0 339 | const sizeArray = [] 340 | const listLen = list.length 341 | // 把整个页面拆分成200*200的很多个方格, 判断每个数据落在哪个方格上 342 | for (let i = 0; i < listLen; i++) { 343 | list[i].__index__ = i 344 | let itemSize = {} 345 | // 获取到每一项的宽和高 346 | if (funcExist) { 347 | // 必须保证返回的每一行的高度一样 348 | itemSize = func && func.call(this, list[i], i) 349 | } else { 350 | itemSize = { 351 | width: func.width, 352 | height: func.height 353 | } 354 | } 355 | itemSize = Object.assign({}, itemSize) 356 | sizeArray.push(itemSize) 357 | // 判断数据落到哪个方格上 358 | // 超过了宽度, 移动到下一行, 再根据高度判断是否需要移动到下一个方格 359 | if (offsetLeft + itemSize.width > compData.width) { 360 | column = 0 361 | offsetLeft = itemSize.width 362 | // Fixed issue #22 363 | if (sizeArray.length >= 2) { 364 | offsetTop += sizeArray[sizeArray.length - 2].height || 0 // 加上最后一个数据的高度 365 | } else { 366 | offsetTop += itemSize.height 367 | } 368 | // offsetTop += sizeArray[sizeArray.length - 2].height // 加上最后一个数据的高度 369 | // 根据高度判断是否需要移动到下一个方格 370 | if (offsetTop >= RECT_SIZE * (line + 1)) { 371 | // fix: 当区块比较大时,会缺失块区域信息 372 | const lastIdx = i - 1 373 | const lastLine = line 374 | 375 | line += parseInt((offsetTop - RECT_SIZE * line) / RECT_SIZE, 10) 376 | 377 | for (let idx = lastLine; idx < line; idx++) { 378 | const key = `${idx}.${column}` 379 | if (!sizeMap[key]) { 380 | sizeMap[key] = [] 381 | } 382 | sizeMap[key].push(lastIdx) 383 | } 384 | } 385 | 386 | // 新起一行的元素, beforeHeight是前一个元素的beforeHeight和height相加 387 | if (i === 0) { 388 | itemSize.beforeHeight = 0 389 | } else { 390 | const prevItemSize = sizeArray[sizeArray.length - 2] 391 | itemSize.beforeHeight = prevItemSize.beforeHeight + prevItemSize.height 392 | } 393 | } else { 394 | if (offsetLeft >= RECT_SIZE * (column + 1)) { 395 | column++ 396 | } 397 | offsetLeft += itemSize.width 398 | if (i === 0) { 399 | itemSize.beforeHeight = 0 400 | } else { 401 | // 同一行的元素, beforeHeight和前面一个元素的beforeHeight一样 402 | itemSize.beforeHeight = sizeArray[sizeArray.length - 2].beforeHeight 403 | } 404 | } 405 | const key = `${line}.${column}` 406 | if (!sizeMap[key]) { 407 | (sizeMap[key] = []) 408 | } 409 | sizeMap[key].push(i) 410 | 411 | // fix: 当区块比较大时,会缺失块区域信息 412 | if (listLen - 1 === i && itemSize.height > RECT_SIZE) { 413 | const lastIdx = line 414 | offsetTop += itemSize.height 415 | line += parseInt((offsetTop - RECT_SIZE * line) / RECT_SIZE, 10) 416 | for (let idx = lastIdx; idx <= line; idx++) { 417 | const key = `${idx}.${column}` 418 | if (!sizeMap[key]) { 419 | sizeMap[key] = [] 420 | } 421 | sizeMap[key].push(i) 422 | } 423 | } 424 | } 425 | // console.log('sizeMap', sizeMap) 426 | const obj = { 427 | array: sizeArray, 428 | map: sizeMap, 429 | totalHeight: sizeArray.length ? sizeArray[sizeArray.length - 1].beforeHeight + 430 | sizeArray[sizeArray.length - 1].height : 0 431 | } 432 | comp.setItemSize(obj) 433 | return obj 434 | } 435 | RecycleContext.prototype.deleteList = function (beginIndex, count, cb) { 436 | this.checkComp() 437 | const id = this.id 438 | if (!recycleData[id]) { 439 | return this 440 | } 441 | recycleData[id].list.splice(beginIndex, count) 442 | this._forceRerender(id, cb) 443 | return this 444 | } 445 | RecycleContext.prototype.updateList = function (beginIndex, list, cb) { 446 | this.checkComp() 447 | const id = this.id 448 | if (!recycleData[id]) { 449 | return this 450 | } 451 | const len = recycleData[id].list.length 452 | for (let i = 0; i < list.length && beginIndex < len; i++) { 453 | recycleData[id].list[beginIndex++] = list[i] 454 | } 455 | this._forceRerender(id, cb) 456 | return this 457 | } 458 | RecycleContext.prototype.update = RecycleContext.prototype.updateList 459 | RecycleContext.prototype.splice = function (begin, deleteCount, appendList, cb) { 460 | this.checkComp() 461 | const id = this.id 462 | const dataKey = this.dataKey 463 | // begin是数组 464 | if (typeof begin === 'object' && begin.length) { 465 | cb = deleteCount 466 | appendList = begin 467 | } 468 | if (typeof appendList === 'function') { 469 | cb = appendList 470 | appendList = [] 471 | } 472 | if (!recycleData[id]) { 473 | recycleData[id] = { 474 | key: dataKey, 475 | id, 476 | list: appendList || [], 477 | sizeMap: {}, 478 | sizeArray: [] 479 | } 480 | } else { 481 | recycleData[id].dataKey = dataKey 482 | const list = recycleData[id].list 483 | if (appendList && appendList.length) { 484 | list.splice(begin, deleteCount, ...appendList) 485 | } else { 486 | list.splice(begin, deleteCount) 487 | } 488 | } 489 | this._forceRerender(id, cb) 490 | return this 491 | } 492 | 493 | RecycleContext.prototype.append = RecycleContext.prototype.appendList 494 | 495 | RecycleContext.prototype.destroy = function () { 496 | if (this.useInPage) { 497 | this.page.onPullDownRefresh = this.oldPullDownRefresh 498 | this.page.onReachBottom = this.oldReachBottom 499 | this.page.onPageScroll = this.oldPageScroll 500 | this.oldPageScroll = this.oldReachBottom = this.oldPullDownRefresh = null 501 | } 502 | this.page = null 503 | this.comp = null 504 | if (recycleData[this.id]) { 505 | delete recycleData[this.id] 506 | } 507 | return this 508 | } 509 | // 重新更新下页面的数据 510 | RecycleContext.prototype.forceUpdate = function (cb, reinitSlot) { 511 | this.checkComp() 512 | if (reinitSlot) { 513 | this.comp.reRender(() => { 514 | this._forceRerender(this.id, cb) 515 | }) 516 | } else { 517 | this._forceRerender(this.id, cb) 518 | } 519 | return this 520 | } 521 | RecycleContext.prototype.getBoundingClientRect = function (index) { 522 | this.checkComp() 523 | if (!recycleData[this.id]) { 524 | return null 525 | } 526 | const sizeArray = recycleData[this.id].sizeArray 527 | if (!sizeArray || !sizeArray.length) { 528 | return null 529 | } 530 | if (typeof index === 'undefined') { 531 | const list = [] 532 | for (let i = 0; i < sizeArray.length; i++) { 533 | list.push({ 534 | left: 0, 535 | top: sizeArray[i].beforeHeight, 536 | width: sizeArray[i].width, 537 | height: sizeArray[i].height 538 | }) 539 | } 540 | return list 541 | } 542 | index = parseInt(index, 10) 543 | if (index >= sizeArray.length || index < 0) return null 544 | return { 545 | left: 0, 546 | top: sizeArray[index].beforeHeight, 547 | width: sizeArray[index].width, 548 | height: sizeArray[index].height 549 | } 550 | } 551 | RecycleContext.prototype.getScrollTop = function () { 552 | this.checkComp() 553 | return this.comp.currentScrollTop || 0 554 | } 555 | // 将px转化为rpx 556 | RecycleContext.prototype.transformRpx = RecycleContext.transformRpx = function (str, addPxSuffix) { 557 | if (typeof str === 'number') str += 'rpx' 558 | return parseFloat(transformRpx.transformRpx(str, addPxSuffix)) 559 | } 560 | RecycleContext.prototype.getViewportItems = function (inViewportPx) { 561 | this.checkComp() 562 | const indexes = this.comp.getIndexesInViewport(inViewportPx) 563 | if (indexes.length <= 0) return [] 564 | const viewportItems = [] 565 | const list = recycleData[this.id].list 566 | for (let i = 0; i < indexes.length; i++) { 567 | viewportItems.push(list[indexes[i]]) 568 | } 569 | return viewportItems 570 | } 571 | RecycleContext.prototype.getTotalHeight = function () { 572 | this.checkComp() 573 | return this.comp.getTotalHeight() 574 | } 575 | // 返回完整的列表数据 576 | RecycleContext.prototype.getList = function () { 577 | if (!recycleData[this.id]) { 578 | return [] 579 | } 580 | return recycleData[this.id].list 581 | } 582 | module.exports = RecycleContext 583 | -------------------------------------------------------------------------------- /src/recycle-view.js: -------------------------------------------------------------------------------- 1 | /* eslint complexity: ["error", {"max": 50}] */ 2 | /* eslint-disable indent */ 3 | const DEFAULT_SHOW_SCREENS = 4 4 | const RECT_SIZE = 200 5 | const systemInfo = wx.getSystemInfoSync() 6 | const DEBUG = false 7 | const transformRpx = require('./utils/transformRpx.js').transformRpx 8 | 9 | Component({ 10 | options: { 11 | multipleSlots: true // 在组件定义时的选项中启用多slot支持 12 | }, 13 | relations: { 14 | '../recycle-item/recycle-item': { 15 | type: 'child', // 关联的目标节点应为子节点 16 | linked(target) { 17 | // 检查第一个的尺寸就好了吧 18 | if (!this._hasCheckSize) { 19 | this._hasCheckSize = true 20 | const size = this.boundingClientRect(this._pos.beginIndex) 21 | if (!size) { 22 | return 23 | } 24 | setTimeout(() => { 25 | try { 26 | target.createSelectorQuery().select('.wx-recycle-item').boundingClientRect((rect) => { 27 | if (rect && (rect.width !== size.width || rect.height !== size.height)) { 28 | // eslint-disable-next-line no-console 29 | console.warn('[recycle-view] the size in is not the same with param ' + 30 | `itemSize, expect {width: ${rect.width}, height: ${rect.height}} but got ` + 31 | `{width: ${size.width}, height: ${size.height}}`) 32 | } 33 | }).exec() 34 | } catch (e) { 35 | // do nothing 36 | } 37 | }, 10) 38 | } 39 | } 40 | } 41 | }, 42 | /** 43 | * 组件的属性列表 44 | */ 45 | properties: { 46 | debug: { 47 | type: Boolean, 48 | value: false 49 | }, 50 | scrollY: { 51 | type: Boolean, 52 | value: true, 53 | }, 54 | batch: { 55 | type: Boolean, 56 | value: false, 57 | public: true, 58 | observer: '_recycleInnerBatchDataChanged' 59 | }, 60 | batchKey: { 61 | type: String, 62 | value: 'batchSetRecycleData', 63 | public: true, 64 | }, 65 | scrollTop: { 66 | type: Number, 67 | value: 0, 68 | public: true, 69 | observer: '_scrollTopChanged', 70 | observeAssignments: true 71 | }, 72 | height: { 73 | type: Number, 74 | value: systemInfo.windowHeight, 75 | public: true, 76 | observer: '_heightChanged' 77 | }, 78 | width: { 79 | type: Number, 80 | value: systemInfo.windowWidth, 81 | public: true, 82 | observer: '_widthChanged' 83 | }, 84 | // 距顶部/左边多远时,触发bindscrolltoupper 85 | upperThreshold: { 86 | type: Number, 87 | value: 50, 88 | public: true, 89 | }, 90 | // 距底部/右边多远时,触发bindscrolltolower 91 | lowerThreshold: { 92 | type: Number, 93 | value: 50, 94 | public: true, 95 | }, 96 | scrollToIndex: { 97 | type: Number, 98 | public: true, 99 | value: 0, 100 | observer: '_scrollToIndexChanged', 101 | observeAssignments: true 102 | }, 103 | scrollWithAnimation: { 104 | type: Boolean, 105 | public: true, 106 | value: false 107 | }, 108 | enableBackToTop: { 109 | type: Boolean, 110 | public: true, 111 | value: false 112 | }, 113 | // 是否节流,默认是 114 | throttle: { 115 | type: Boolean, 116 | public: true, 117 | value: true 118 | }, 119 | placeholderImage: { 120 | type: String, 121 | public: true, 122 | value: '' 123 | }, 124 | screen: { // 默认渲染多少屏的数据 125 | type: Number, 126 | public: true, 127 | value: DEFAULT_SHOW_SCREENS 128 | } 129 | }, 130 | 131 | /** 132 | * 组件的初始数据 133 | */ 134 | data: { 135 | innerBeforeHeight: 0, 136 | innerAfterHeight: 0, 137 | innerScrollTop: 0, 138 | innerScrollIntoView: '', 139 | placeholderImageStr: '', 140 | totalHeight: 0, 141 | useInPage: false 142 | }, 143 | attached() { 144 | if (this.data.placeholderImage) { 145 | this.setData({ 146 | placeholderImageStr: transformRpx(this.data.placeholderImage, true) 147 | }) 148 | } 149 | this.setItemSize({ 150 | array: [], 151 | map: {}, 152 | totalHeight: 0 153 | }) 154 | }, 155 | ready() { 156 | this._initPosition(() => { 157 | this._isReady = true // DOM结构ready了 158 | // 有一个更新的timer在了 159 | if (this._updateTimerId) return 160 | 161 | this._scrollViewDidScroll({ 162 | detail: { 163 | scrollLeft: this._pos.left, 164 | scrollTop: this._pos.top, 165 | ignoreScroll: true 166 | } 167 | }, true) 168 | }) 169 | }, 170 | detached() { 171 | this.page = null 172 | // 销毁对应的RecycleContext 173 | if (this.context) { 174 | this.context.destroy() 175 | this.context = null 176 | } 177 | }, 178 | /** 179 | * 组件的方法列表 180 | */ 181 | methods: { 182 | _log(...args) { 183 | if (!DEBUG && !this.data.debug) return 184 | const h = new Date() 185 | const str = `${h.getHours()}:${h.getMinutes()}:${h.getSeconds()}.${h.getMilliseconds()}` 186 | Array.prototype.splice.call(args, 0, 0, str) 187 | // eslint-disable-next-line no-console 188 | console.log(...args) 189 | }, 190 | _scrollToUpper(e) { 191 | this.triggerEvent('scrolltoupper', e.detail) 192 | }, 193 | _scrollToLower(e) { 194 | this.triggerEvent('scrolltolower', e.detail) 195 | }, 196 | _beginToScroll() { 197 | if (!this._lastScrollTop) { 198 | this._lastScrollTop = this._pos && (this._pos.top || 0) 199 | } 200 | }, 201 | _clearList(cb) { 202 | this.currentScrollTop = 0 203 | this._lastScrollTop = 0 204 | const pos = this._pos 205 | pos.beginIndex = this._pos.endIndex = -1 206 | pos.afterHeight = pos.minTop = pos.maxTop = 0 207 | this.page._recycleViewportChange({ 208 | detail: { 209 | data: pos, 210 | id: this.id 211 | } 212 | }, cb) 213 | }, 214 | // 判断RecycleContext是否Ready 215 | _isValid() { 216 | return this.page && this.context && this.context.isDataReady 217 | }, 218 | // eslint-disable-next-line no-complexity 219 | _scrollViewDidScroll(e, force) { 220 | // 如果RecycleContext还没有初始化, 不做任何事情 221 | if (!this._isValid()) { 222 | return 223 | } 224 | // 监测白屏时间 225 | if (!e.detail.ignoreScroll) { 226 | this.triggerEvent('scroll', e.detail) 227 | } 228 | this.currentScrollTop = e.detail.scrollTop 229 | // 高度为0的情况, 不做任何渲染逻辑 230 | if (!this._pos.height || !this.sizeArray.length) { 231 | // 没有任何数据的情况下, 直接清理所有的状态 232 | this._clearList(e.detail.cb) 233 | return 234 | } 235 | 236 | // 在scrollWithAnimation动画最后会触发一次scroll事件, 这次scroll事件必须要被忽略 237 | if (this._isScrollingWithAnimation) { 238 | this._isScrollingWithAnimation = false 239 | return 240 | } 241 | const pos = this._pos 242 | const that = this 243 | const scrollLeft = e.detail.scrollLeft 244 | const scrollTop = e.detail.scrollTop 245 | const scrollDistance = Math.abs(scrollTop - this._lastScrollTop) 246 | if (!force && (Math.abs(scrollTop - pos.top) < pos.height * 1.5)) { 247 | this._log('【not exceed height') 248 | return 249 | } 250 | this._lastScrollTop = scrollTop 251 | const SHOW_SCREENS = this.data.screen // 固定4屏幕 252 | this._log('SHOW_SCREENS', SHOW_SCREENS, scrollTop) 253 | this._calcViewportIndexes(scrollLeft, scrollTop, 254 | (beginIndex, endIndex, minTop, afterHeight, maxTop) => { 255 | that._log('scrollDistance', scrollDistance, 'indexes', beginIndex, endIndex) 256 | // 渲染的数据不变 257 | if (!force && pos.beginIndex === beginIndex && pos.endIndex === endIndex && 258 | pos.minTop === minTop && pos.afterHeight === afterHeight) { 259 | that._log('------------is the same beginIndex and endIndex') 260 | return 261 | } 262 | // 如果这次渲染的范围比上一次的范围小,则忽略 263 | that._log('【check】before setData, old pos is', pos.minTop, pos.maxTop, minTop, maxTop) 264 | that._throttle = false 265 | pos.left = scrollLeft 266 | pos.top = scrollTop 267 | pos.beginIndex = beginIndex 268 | pos.endIndex = endIndex 269 | // console.log('render indexes', endIndex - beginIndex + 1, endIndex, beginIndex) 270 | pos.minTop = minTop 271 | pos.maxTop = maxTop 272 | pos.afterHeight = afterHeight 273 | pos.ignoreBeginIndex = pos.ignoreEndIndex = -1 274 | that.page._recycleViewportChange({ 275 | detail: { 276 | data: that._pos, 277 | id: that.id 278 | } 279 | }, () => { 280 | if (e.detail.cb) { 281 | e.detail.cb() 282 | } 283 | }) 284 | }) 285 | }, 286 | // 计算在视窗内渲染的数据 287 | _calcViewportIndexes(left, top, cb) { 288 | const that = this 289 | // const st = +new Date 290 | this._getBeforeSlotHeight(() => { 291 | const { 292 | beginIndex, endIndex, minTop, afterHeight, maxTop 293 | } = that.__calcViewportIndexes(left, top) 294 | if (cb) { 295 | cb(beginIndex, endIndex, minTop, afterHeight, maxTop) 296 | } 297 | }) 298 | }, 299 | _getBeforeSlotHeight(cb) { 300 | if (typeof this.data.beforeSlotHeight !== 'undefined') { 301 | if (cb) { 302 | cb(this.data.beforeSlotHeight) 303 | } 304 | } else { 305 | this.reRender(cb) 306 | } 307 | }, 308 | _getAfterSlotHeight(cb) { 309 | if (typeof this.data.afterSlotHeight !== 'undefined') { 310 | if (cb) { 311 | cb(this.data.afterSlotHeight) 312 | } 313 | // cb && cb(this.data.afterSlotHeight) 314 | } else { 315 | this.reRender(cb) 316 | } 317 | }, 318 | _getIndexes(minTop, maxTop) { 319 | if (minTop === maxTop && maxTop === 0) { 320 | return { 321 | beginIndex: -1, 322 | endIndex: -1 323 | } 324 | } 325 | const startLine = Math.floor(minTop / RECT_SIZE) 326 | const endLine = Math.ceil(maxTop / RECT_SIZE) 327 | const rectEachLine = Math.floor(this.data.width / RECT_SIZE) 328 | let beginIndex 329 | let endIndex 330 | const sizeMap = this.sizeMap 331 | for (let i = startLine; i <= endLine; i++) { 332 | for (let col = 0; col < rectEachLine; col++) { 333 | const key = `${i}.${col}` 334 | // 找到sizeMap里面的最小值和最大值即可 335 | if (!sizeMap[key]) continue 336 | for (let j = 0; j < sizeMap[key].length; j++) { 337 | if (typeof beginIndex === 'undefined') { 338 | beginIndex = endIndex = sizeMap[key][j] 339 | continue 340 | } 341 | if (beginIndex > sizeMap[key][j]) { 342 | beginIndex = sizeMap[key][j] 343 | } else if (endIndex < sizeMap[key][j]) { 344 | endIndex = sizeMap[key][j] 345 | } 346 | } 347 | } 348 | } 349 | return { 350 | beginIndex, 351 | endIndex 352 | } 353 | }, 354 | _isIndexValid(beginIndex, endIndex) { 355 | if (typeof beginIndex === 'undefined' || beginIndex === -1 || 356 | typeof endIndex === 'undefined' || endIndex === -1 || endIndex >= this.sizeArray.length) { 357 | return false 358 | } 359 | return true 360 | }, 361 | __calcViewportIndexes(left, top) { 362 | if (!this.sizeArray.length) return {} 363 | const pos = this._pos 364 | if (typeof left === 'undefined') { 365 | (left = pos.left) 366 | } 367 | if (typeof top === 'undefined') { 368 | (top = pos.top) 369 | } 370 | // top = Math.max(top, this.data.beforeSlotHeight) 371 | const beforeSlotHeight = this.data.beforeSlotHeight || 0 372 | // 和direction无关了 373 | const SHOW_SCREENS = this.data.screen 374 | let minTop = top - pos.height * SHOW_SCREENS - beforeSlotHeight 375 | let maxTop = top + pos.height * SHOW_SCREENS - beforeSlotHeight 376 | // maxTop或者是minTop超出了范围 377 | if (maxTop > this.totalHeight) { 378 | minTop -= (maxTop - this.totalHeight) 379 | maxTop = this.totalHeight 380 | } 381 | if (minTop < beforeSlotHeight) { 382 | maxTop += Math.min(beforeSlotHeight - minTop, this.totalHeight) 383 | minTop = 0 384 | } 385 | // 计算落在minTop和maxTop之间的方格有哪些 386 | const indexObj = this._getIndexes(minTop, maxTop) 387 | const beginIndex = indexObj.beginIndex 388 | let endIndex = indexObj.endIndex 389 | if (endIndex >= this.sizeArray.length) { 390 | endIndex = this.sizeArray.length - 1 391 | } 392 | // 校验一下beginIndex和endIndex的有效性, 393 | if (!this._isIndexValid(beginIndex, endIndex)) { 394 | return { 395 | beginIndex: -1, 396 | endIndex: -1, 397 | minTop: 0, 398 | afterHeight: 0, 399 | maxTop: 0 400 | } 401 | } 402 | // 计算白屏的默认占位的区域 403 | const maxTopFull = this.sizeArray[endIndex].beforeHeight + this.sizeArray[endIndex].height 404 | const minTopFull = this.sizeArray[beginIndex].beforeHeight 405 | 406 | // console.log('render indexes', beginIndex, endIndex) 407 | const afterHeight = this.totalHeight - maxTopFull 408 | return { 409 | beginIndex, 410 | endIndex, 411 | minTop: minTopFull, // 取整, beforeHeight的距离 412 | afterHeight, 413 | maxTop, 414 | } 415 | }, 416 | setItemSize(size) { 417 | this.sizeArray = size.array 418 | this.sizeMap = size.map 419 | if (size.totalHeight !== this.totalHeight) { 420 | // console.log('---totalHeight is', size.totalHeight); 421 | this.setData({ 422 | totalHeight: size.totalHeight, 423 | useInPage: this.useInPage || false 424 | }) 425 | } 426 | this.totalHeight = size.totalHeight 427 | }, 428 | setList(key, newList) { 429 | this._currentSetDataKey = key 430 | this._currentSetDataList = newList 431 | }, 432 | setPage(page) { 433 | this.page = page 434 | }, 435 | forceUpdate(cb, reInit) { 436 | if (!this._isReady) { 437 | if (this._updateTimerId) { 438 | // 合并多次的forceUpdate 439 | clearTimeout(this._updateTimerId) 440 | } 441 | this._updateTimerId = setTimeout(() => { 442 | this.forceUpdate(cb, reInit) 443 | }, 10) 444 | return 445 | } 446 | this._updateTimerId = null 447 | const that = this 448 | if (reInit) { 449 | this.reRender(() => { 450 | that._scrollViewDidScroll({ 451 | detail: { 452 | scrollLeft: that._pos.left, 453 | scrollTop: that.currentScrollTop || that.data.scrollTop || 0, 454 | ignoreScroll: true, 455 | cb 456 | } 457 | }, true) 458 | }) 459 | } else { 460 | this._scrollViewDidScroll({ 461 | detail: { 462 | scrollLeft: that._pos.left, 463 | scrollTop: that.currentScrollTop || that.data.scrollTop || 0, 464 | ignoreScroll: true, 465 | cb 466 | } 467 | }, true) 468 | } 469 | }, 470 | _initPosition(cb) { 471 | const that = this 472 | that._pos = { 473 | left: that.data.scrollLeft || 0, 474 | top: that.data.scrollTop || 0, 475 | width: this.data.width, 476 | height: Math.max(500, this.data.height), // 一个屏幕的高度 477 | direction: 0 478 | } 479 | this.reRender(cb) 480 | }, 481 | _widthChanged(newVal) { 482 | if (!this._isReady) return newVal 483 | this._pos.width = newVal 484 | this.forceUpdate() 485 | return newVal 486 | }, 487 | _heightChanged(newVal) { 488 | if (!this._isReady) return newVal 489 | this._pos.height = Math.max(500, newVal) 490 | this.forceUpdate() 491 | return newVal 492 | }, 493 | reRender(cb) { 494 | let beforeSlotHeight 495 | let afterSlotHeight 496 | const that = this 497 | // const reRenderStart = Date.now() 498 | function newCb() { 499 | if (that._lastBeforeSlotHeight !== beforeSlotHeight || 500 | that._lastAfterSlotHeight !== afterSlotHeight) { 501 | that.setData({ 502 | hasBeforeSlotHeight: true, 503 | hasAfterSlotHeight: true, 504 | beforeSlotHeight, 505 | afterSlotHeight 506 | }) 507 | } 508 | that._lastBeforeSlotHeight = beforeSlotHeight 509 | that._lastAfterSlotHeight = afterSlotHeight 510 | // console.log('_getBeforeSlotHeight use time', Date.now() - reRenderStart) 511 | if (cb) { 512 | cb() 513 | } 514 | } 515 | // 重新渲染事件发生 516 | let beforeReady = false 517 | let afterReady = false 518 | // fix:#16 确保获取slot节点实际高度 519 | this.setData({ 520 | hasBeforeSlotHeight: false, 521 | hasAfterSlotHeight: false, 522 | }, () => { 523 | this.createSelectorQuery().select('.slot-before').boundingClientRect((rect) => { 524 | beforeSlotHeight = rect.height 525 | beforeReady = true 526 | if (afterReady) { 527 | if (newCb) { newCb() } 528 | } 529 | }).exec() 530 | this.createSelectorQuery().select('.slot-after').boundingClientRect((rect) => { 531 | afterSlotHeight = rect.height 532 | afterReady = true 533 | if (beforeReady) { 534 | if (newCb) { newCb() } 535 | } 536 | }).exec() 537 | }) 538 | }, 539 | _setInnerBeforeAndAfterHeight(obj) { 540 | if (typeof obj.beforeHeight !== 'undefined') { 541 | this._tmpBeforeHeight = obj.beforeHeight 542 | } 543 | if (obj.afterHeight) { 544 | this._tmpAfterHeight = obj.afterHeight 545 | } 546 | }, 547 | _recycleInnerBatchDataChanged(cb) { 548 | if (typeof this._tmpBeforeHeight !== 'undefined') { 549 | const setObj = { 550 | innerBeforeHeight: this._tmpBeforeHeight || 0, 551 | innerAfterHeight: this._tmpAfterHeight || 0 552 | } 553 | if (typeof this._tmpInnerScrollTop !== 'undefined') { 554 | setObj.innerScrollTop = this._tmpInnerScrollTop 555 | } 556 | const pageObj = {} 557 | let hasPageData = false 558 | if (typeof this._currentSetDataKey !== 'undefined') { 559 | pageObj[this._currentSetDataKey] = this._currentSetDataList 560 | hasPageData = true 561 | } 562 | const saveScrollWithAnimation = this.data.scrollWithAnimation 563 | const groupSetData = () => { 564 | // 如果有分页数据的话 565 | if (hasPageData) { 566 | this.page.setData(pageObj) 567 | } 568 | this.setData(setObj, () => { 569 | this.setData({ 570 | scrollWithAnimation: saveScrollWithAnimation 571 | }) 572 | if (typeof cb === 'function') { 573 | cb() 574 | } 575 | }) 576 | } 577 | groupSetData() 578 | delete this._currentSetDataKey 579 | delete this._currentSetDataList 580 | this._tmpBeforeHeight = undefined 581 | this._tmpAfterHeight = undefined 582 | this._tmpInnerScrollTop = undefined 583 | } 584 | }, 585 | _renderByScrollTop(scrollTop) { 586 | // 先setData把目标位置的数据补齐 587 | this._scrollViewDidScroll({ 588 | detail: { 589 | scrollLeft: this._pos.scrollLeft, 590 | scrollTop, 591 | ignoreScroll: true 592 | } 593 | }, true) 594 | if (this.data.scrollWithAnimation) { 595 | this._isScrollingWithAnimation = true 596 | } 597 | this.setData({ 598 | innerScrollTop: scrollTop 599 | }) 600 | }, 601 | _scrollTopChanged(newVal, oldVal) { 602 | // if (newVal === oldVal && newVal === 0) return 603 | if (!this._isInitScrollTop && newVal === 0) { 604 | this._isInitScrollTop = true 605 | return newVal 606 | } 607 | this.currentScrollTop = newVal 608 | if (!this._isReady) { 609 | if (this._scrollTopTimerId) { 610 | clearTimeout(this._scrollTopTimerId) 611 | } 612 | this._scrollTopTimerId = setTimeout(() => { 613 | this._scrollTopChanged(newVal, oldVal) 614 | }, 10) 615 | return newVal 616 | } 617 | this._isInitScrollTop = true 618 | this._scrollTopTimerId = null 619 | // this._lastScrollTop = oldVal 620 | if (typeof this._lastScrollTop === 'undefined') { 621 | this._lastScrollTop = this.data.scrollTop 622 | } 623 | // 滑动距离小于一个屏幕的高度, 直接setData 624 | if (Math.abs(newVal - this._lastScrollTop) < this._pos.height) { 625 | this.setData({ 626 | innerScrollTop: newVal 627 | }) 628 | return newVal 629 | } 630 | if (!this._isScrollTopChanged) { 631 | // 首次的值需要延后一点执行才能生效 632 | setTimeout(() => { 633 | this._isScrollTopChanged = true 634 | this._renderByScrollTop(newVal) 635 | }, 10) 636 | } else { 637 | this._renderByScrollTop(newVal) 638 | } 639 | return newVal 640 | }, 641 | _scrollToIndexChanged(newVal, oldVal) { 642 | // if (newVal === oldVal && newVal === 0) return 643 | // 首次滚动到0的不执行 644 | if (!this._isInitScrollToIndex && newVal === 0) { 645 | this._isInitScrollToIndex = true 646 | return newVal 647 | } 648 | if (!this._isReady) { 649 | if (this._scrollToIndexTimerId) { 650 | clearTimeout(this._scrollToIndexTimerId) 651 | } 652 | this._scrollToIndexTimerId = setTimeout(() => { 653 | this._scrollToIndexChanged(newVal, oldVal) 654 | }, 10) 655 | return newVal 656 | } 657 | this._isInitScrollToIndex = true 658 | this._scrollToIndexTimerId = null 659 | if (typeof this._lastScrollTop === 'undefined') { 660 | this._lastScrollTop = this.data.scrollTop 661 | } 662 | const rect = this.boundingClientRect(newVal) 663 | if (!rect) return newVal 664 | // console.log('rect top', rect, this.data.beforeSlotHeight) 665 | const calScrollTop = rect.top + (this.data.beforeSlotHeight || 0) 666 | this.currentScrollTop = calScrollTop 667 | if (Math.abs(calScrollTop - this._lastScrollTop) < this._pos.height) { 668 | this.setData({ 669 | innerScrollTop: calScrollTop 670 | }) 671 | return newVal 672 | } 673 | if (!this._isScrollToIndexChanged) { 674 | setTimeout(() => { 675 | this._isScrollToIndexChanged = true 676 | this._renderByScrollTop(calScrollTop) 677 | }, 10) 678 | } else { 679 | this._renderByScrollTop(calScrollTop) 680 | } 681 | return newVal 682 | }, 683 | // 提供给开发者使用的接口 684 | boundingClientRect(idx) { 685 | if (idx < 0 || idx >= this.sizeArray.length) { 686 | return null 687 | } 688 | return { 689 | left: 0, 690 | top: this.sizeArray[idx].beforeHeight, 691 | width: this.sizeArray[idx].width, 692 | height: this.sizeArray[idx].height 693 | } 694 | }, 695 | // 获取当前出现在屏幕内数据项, 返回数据项组成的数组 696 | // 参数inViewportPx表示当数据项至少有多少像素出现在屏幕内才算是出现在屏幕内,默认是1 697 | getIndexesInViewport(inViewportPx) { 698 | if (!inViewportPx) { 699 | (inViewportPx = 1) 700 | } 701 | const scrollTop = this.currentScrollTop 702 | let minTop = scrollTop + inViewportPx 703 | if (minTop < 0) minTop = 0 704 | let maxTop = scrollTop + this.data.height - inViewportPx 705 | if (maxTop > this.totalHeight) maxTop = this.totalHeight 706 | const indexes = [] 707 | for (let i = 0; i < this.sizeArray.length; i++) { 708 | if (this.sizeArray[i].beforeHeight + this.sizeArray[i].height >= minTop && 709 | this.sizeArray[i].beforeHeight <= maxTop) { 710 | indexes.push(i) 711 | } 712 | if (this.sizeArray[i].beforeHeight > maxTop) break 713 | } 714 | return indexes 715 | }, 716 | getTotalHeight() { 717 | return this.totalHeight 718 | }, 719 | setUseInPage(useInPage) { 720 | this.useInPage = useInPage 721 | }, 722 | setPlaceholderImage(svgs, size) { 723 | const fill = 'style=\'fill:rgb(204,204,204);\'' 724 | const placeholderImages = [`data:image/svg+xml,%3Csvg height='${size.height}' width='${size.width}' xmlns='http://www.w3.org/2000/svg'%3E`] 725 | svgs.forEach(svg => { 726 | placeholderImages.push(`%3Crect width='${svg.width}' x='${svg.left}' height='${svg.height}' y='${svg.top}' ${fill} /%3E`) 727 | }) 728 | placeholderImages.push('%3C/svg%3E') 729 | this.setData({ 730 | placeholderImageStr: placeholderImages.join('') 731 | }) 732 | } 733 | } 734 | }) 735 | --------------------------------------------------------------------------------