├── 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 |
13 |
14 |
15 |
16 | {{item.idx+1}}. {{item.title}}
17 |
18 |
19 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------