├── .gitignore ├── .vscode └── settings.json ├── .yarnrc ├── FUNDING.yml ├── README.md ├── assets ├── .DS_Store ├── alipay.JPG └── wechat.JPG ├── circle.yml ├── config └── default.json ├── example └── switchMap.ts ├── package.json ├── server ├── blls.ts ├── global.d.ts ├── index.ts ├── router.ts └── utils.ts ├── src ├── FileUploader.ts ├── app.ts ├── index.html ├── lib.ts ├── main.ts └── style.css ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | chunks 4 | .awcache 5 | dist 6 | 7 | 8 | # Created by https://www.gitignore.io/api/node 9 | # Edit at https://www.gitignore.io/?templates=node 10 | 11 | ### Node ### 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # TypeScript v1 declaration files 56 | typings/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | 83 | # next.js build output 84 | .next 85 | 86 | # nuxt.js build output 87 | .nuxt 88 | 89 | # vuepress build output 90 | .vuepress/dist 91 | 92 | # Serverless directories 93 | .serverless/ 94 | 95 | # FuseBox cache 96 | .fusebox/ 97 | 98 | # DynamoDB Local files 99 | .dynamodb/ 100 | 101 | # End of https://www.gitignore.io/api/node 102 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org" 2 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: learning-rxjs 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # learning-rxjs 2 | 3 | [![Financial Contributors on Open Collective](https://opencollective.com/learning-rxjs/all/badge.svg?label=financial+contributors)](https://opencollective.com/learning-rxjs) [![Greenkeeper badge](https://badges.greenkeeper.io/Brooooooklyn/learning-rxjs.svg)](https://greenkeeper.io/) 4 | 5 | 6 | Learning RxJS step by step 7 | 8 | 1. Clone this repo 9 | 2. Checkout to seed branch 10 | 3. Implement the Application follow the article 11 | 12 | > 使用 yarn 来保证项目可以正常运行 13 | 14 | ## 更新计划 15 | - 使用最新版 `RxJS` 与 `TypeScript` 升级已有代码 16 | - 增加 React 与 Vue 中使用 RxJS 最佳实践系列文章 17 | - 随使用的工具升级,长期维护文章与源码 18 | 19 | 支持更新计划: https://opencollective.com/learning-rxjs 20 | 21 | ## Articles 22 | - [Hello RxJS](https://zhuanlan.zhihu.com/p/23331432) 23 | - [用 RxJS 连接世界](https://zhuanlan.zhihu.com/p/23464709) 24 | - [使用 RxJS 掌控异步](https://zhuanlan.zhihu.com/p/25059824) 25 | 26 | ## Buy me a cup of coffee 27 |

28 | 29 | 30 |

31 | 32 | ## Contributors 33 | 34 | ### Code Contributors 35 | 36 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 37 | 38 | 39 | ### Financial Contributors 40 | 41 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/learning-rxjs/contribute)] 42 | 43 | #### Individuals 44 | 45 | 46 | 47 | #### Organizations 48 | 49 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/learning-rxjs/contribute)] 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brooooooklyn/learning-rxjs/4448e8e742d6f52ffd30ef65b6573568717f8c00/assets/.DS_Store -------------------------------------------------------------------------------- /assets/alipay.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brooooooklyn/learning-rxjs/4448e8e742d6f52ffd30ef65b6573568717f8c00/assets/alipay.JPG -------------------------------------------------------------------------------- /assets/wechat.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brooooooklyn/learning-rxjs/4448e8e742d6f52ffd30ef65b6573568717f8c00/assets/wechat.JPG -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8 4 | 5 | dependencies: 6 | override: 7 | - yarn global add greenkeeper-lockfile@1 8 | - yarn 9 | cache_directories: 10 | - ~/.cache/yarn 11 | 12 | test: 13 | before: 14 | - greenkeeper-lockfile-update 15 | override: 16 | - yarn lint 17 | - yarn compile_serve 18 | - yarn build 19 | post: 20 | - greenkeeper-lockfile-upload 21 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 5000, 3 | "FILE_API_HOST": "127.0.0.1", 4 | "CHUNK_SIZE": 1048576 5 | } -------------------------------------------------------------------------------- /example/switchMap.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer } from 'rxjs' 2 | 3 | const stream: Observable = Observable.create((observer: Observer) => { 4 | let i = 0 5 | const intervalId = setInterval(() => { 6 | observer.next(++i) 7 | }, 1000) 8 | return () => clearInterval(intervalId) 9 | }) 10 | 11 | function createIntervalObservable(base: number): Observable { 12 | let i = 0 13 | return Observable.create((observer: Observer) => { 14 | const intervalId = setInterval(() => { 15 | observer.next(`base: ${base}, value: ${++i}`) 16 | }, 200) 17 | return () => { 18 | clearInterval(intervalId) 19 | console.log(`unsubscribe base: ${base}`) 20 | } 21 | }) 22 | } 23 | 24 | stream.switchMap(createIntervalObservable) 25 | .subscribe(result => console.log(result)) 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learning-rxjs", 3 | "version": "0.1.0", 4 | "description": "TODO-MVC implement by RxJS", 5 | "main": "index.js", 6 | "scripts": { 7 | "compile_serve": "tsc ./server/index.ts ./server/global.d.ts -m commonjs --outDir lib --target ES2015 --experimentalDecorators --moduleResolution node --noImplicitAny --noImplicitThis --noImplicitReturns --noUnusedParameters --suppressImplicitAnyIndexErrors", 8 | "dev": "webpack-dev-server --inline --progress --port 3000 --content-base src", 9 | "lint": "tslint --type-check -p ./tsconfig.json src/**/*.ts server/**/*.ts", 10 | "serve": "npm run compile_serve && node lib/index.js", 11 | "start": "npm-run-all -p serve dev", 12 | "test": "echo \"Error: no test specified\" && exit 1", 13 | "watch": "tsc ./server/index.ts ./server/global.d.ts -m commonjs --outDir lib --target ES2015 --experimentalDecorators --moduleResolution node -w & nodemon lib/index.js", 14 | "build": "NODE_ENV=production webpack" 15 | }, 16 | "keywords": [ 17 | "RxJS", 18 | "TypeScript" 19 | ], 20 | "author": "lynweklm@gmail.com", 21 | "license": "MIT", 22 | "dependencies": { 23 | "bootstrap": "^4.0.0", 24 | "config": "^1.30.0", 25 | "jquery": "^3.3.1", 26 | "koa": "^2.5.0", 27 | "koa-better-body": "^3.0.4", 28 | "koa-bodyparser": "^4.2.0", 29 | "koa-router": "^7.4.0", 30 | "os": "^0.1.1", 31 | "popper.js": "^1.14.1", 32 | "raw-body": "^2.3.2", 33 | "rxjs": "^5.5.8", 34 | "spark-md5": "^3.0.0" 35 | }, 36 | "devDependencies": { 37 | "@types/jquery": "^3.3.1", 38 | "@types/koa": "*", 39 | "@types/koa-bodyparser": "^4.2.0", 40 | "@types/koa-router": "^7.0.27", 41 | "@types/node": "~9.6.1", 42 | "@types/raw-body": "^2.3.0", 43 | "awesome-typescript-loader": "^4.0.1", 44 | "css-loader": "^0.28.11", 45 | "file-loader": "^1.1.11", 46 | "html-webpack-plugin": "^3.1.0", 47 | "nodemon": "^1.17.3", 48 | "npm-run-all": "^4.1.2", 49 | "raw-loader": "^0.5.1", 50 | "source-map-loader": "^0.2.3", 51 | "style-loader": "^0.21.0", 52 | "to-string-loader": "^1.1.5", 53 | "tslint": "^5.9.1", 54 | "tslint-eslint-rules": "^5.1.0", 55 | "tslint-loader": "^3.6.0", 56 | "typescript": "^2.8.1", 57 | "webpack": "^4.4.1", 58 | "webpack-cli": "^2.0.13", 59 | "webpack-dev-server": "^3.1.1", 60 | "webpack-merge": "^4.1.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/blls.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | import * as fs from 'fs' 3 | import { join } from 'path' 4 | import * as rawBody from 'raw-body' 5 | import Router from './router' 6 | import { IRouterContext } from 'koa-router' 7 | import { genKey } from './utils' 8 | 9 | const config = require('config') 10 | 11 | interface FileMeta { 12 | name: string 13 | chunks: number 14 | } 15 | 16 | @Router.root('/api') 17 | export class Blls { 18 | 19 | private static fileKeyPairs = new Map() 20 | 21 | @Router.post('/upload/chunk') 22 | async getChunksMeta(ctx: IRouterContext, next: KoaNext) { 23 | const { 24 | fileSize, 25 | fileMD5, 26 | lastUpdated, 27 | fileName 28 | } = ctx.request.body 29 | const fileInfo = { fileSize, fileMD5, lastUpdated, fileName } 30 | const chunkSize = config.CHUNK_SIZE 31 | const chunks = Math.ceil(fileSize / chunkSize) 32 | const buffer = Buffer.concat([new Buffer(JSON.stringify(fileInfo)), crypto.randomBytes(1024)]) 33 | const fileKey = genKey(fileInfo, buffer) 34 | Blls.fileKeyPairs.set(fileKey, { 35 | name: fileName, chunks 36 | }) 37 | ctx.body = { chunkSize, chunks, fileKey, fileSize: parseInt(fileSize) } 38 | await next() 39 | } 40 | 41 | @Router.post('/upload/chunk/:fileKey') 42 | upload(ctx: IRouterContext, next: KoaNext) { 43 | const { chunk, chunks } = ctx.request.query 44 | if (chunk && chunks) { 45 | return this.uploadChunk(ctx, next) 46 | } else if (!chunk && !chunks) { 47 | return this.settle(ctx, next) 48 | } else { 49 | ctx.body = 'bad request' 50 | ctx.status = 400 51 | return next() 52 | } 53 | } 54 | 55 | async uploadChunk(ctx: IRouterContext, next: KoaNext) { 56 | const { fileKey } = ctx.params 57 | const { chunk, chunks } = ctx.request.query 58 | const raw = await new Promise((resolve, reject) => { 59 | rawBody(ctx.req, { 60 | length: ctx.req.headers['content-length'] 61 | }, (err, body) => { 62 | if (err) { 63 | reject(err) 64 | } 65 | resolve(body) 66 | }) 67 | }) 68 | try { 69 | await new Promise((resolve, reject) => { 70 | const fileName = `${fileKey}_${chunk}` 71 | const dir = join(process.cwd(), `chunks`) 72 | if (!fs.existsSync(dir)) { 73 | fs.mkdirSync(dir) 74 | } 75 | fs.writeFile(`${dir}/${fileName}`, raw, (err) => { 76 | if (err) { 77 | reject(err) 78 | } 79 | resolve() 80 | }) 81 | }) 82 | } catch (e) { 83 | ctx.body = e.message ? e.message : e 84 | ctx.status = 500 85 | await next(e) 86 | } 87 | ctx.body = 'ok' 88 | await next() 89 | } 90 | 91 | async settle(ctx: IRouterContext, next: KoaNext) { 92 | const { fileKey } = ctx.params 93 | const { name, chunks } = Blls.fileKeyPairs.get(fileKey) 94 | const dir = join(process.cwd(), `chunks`) 95 | const promises: Promise[] = [] 96 | let blob: Buffer 97 | for (let i = 1; i <= chunks ; i ++) { 98 | const path = `${dir}/${fileKey}_${i}` 99 | const promise = this.readFileAsPromise(path) 100 | .then(newBlob => { 101 | blob = !blob ? newBlob : Buffer.concat([blob, newBlob]) 102 | return this.deleteFileAsPromise(path) 103 | }) 104 | promises.push(promise) 105 | } 106 | try { 107 | await Promise.all(promises) 108 | await this.writeFileAsPromise(`${dir}/${name}`, blob) 109 | } catch (e) { 110 | ctx.status = 500 111 | ctx.body = e.message ? e.message : e 112 | return await next(e) 113 | } 114 | ctx.body = 'ok' 115 | await next() 116 | } 117 | 118 | private writeFileAsPromise(path: string, blob: Buffer) { 119 | return new Promise((resolve, reject) => { 120 | fs.writeFile(path, blob, (err) => { 121 | if (err) { 122 | reject(err) 123 | } 124 | resolve() 125 | }) 126 | }) 127 | } 128 | 129 | private readFileAsPromise(path: string) { 130 | return new Promise((resolve, reject) => { 131 | fs.readFile(path, (err, data) => { 132 | if (err) { 133 | reject(err) 134 | } 135 | resolve(data) 136 | }) 137 | }) 138 | } 139 | 140 | private deleteFileAsPromise(path: string) { 141 | return new Promise((resolve, reject) => { 142 | fs.unlink(path, (err) => { 143 | if (err) { 144 | reject(err) 145 | } 146 | resolve() 147 | }) 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /server/global.d.ts: -------------------------------------------------------------------------------- 1 | declare type KoaNext = (...args: any[]) => Promise 2 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa' 2 | import * as bodyParser from 'koa-bodyparser' 3 | import Router from './router' 4 | import './blls' 5 | 6 | const config = require('config') 7 | 8 | const app = new Koa 9 | 10 | app.use(bodyParser()) 11 | 12 | Router.setRouters(app) 13 | 14 | app.listen(config.port) 15 | 16 | console.log(`app is listening on ${config.port}`) 17 | -------------------------------------------------------------------------------- /server/router.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa' 2 | import * as KoaRouter from 'koa-router' 3 | 4 | type ValidType = 'get' | 'post' | 'put' | 'delete' 5 | 6 | type RouterConfig = { 7 | path: string 8 | method: ValidType 9 | } | string 10 | 11 | class Router { 12 | private static koaRouter = new KoaRouter() 13 | private static routerMap = new Map() 14 | private static routerSet = new Set() 15 | 16 | constructor() { 17 | const allowMethod = 'GET,PUT,DELETE,POST,OPTIONS' 18 | Router.koaRouter.options('*', async (ctx: KoaRouter.IRouterContext, next: any) => { 19 | ctx.status = 200 20 | ctx.res.setHeader('Allow', allowMethod) 21 | ctx.body = allowMethod 22 | await next() 23 | }) 24 | } 25 | 26 | root(path: string) { 27 | return this.decorator(path) 28 | } 29 | 30 | get(path: string) { 31 | return this.decorator({ 32 | path, method: 'get' 33 | }) 34 | } 35 | 36 | post(path: string) { 37 | return this.decorator({ 38 | path, method: 'post' 39 | }) 40 | } 41 | 42 | put(path: string) { 43 | return this.decorator({ 44 | path, method: 'put' 45 | }) 46 | } 47 | 48 | delete(path: string) { 49 | return this.decorator({ 50 | path, method: 'delete' 51 | }) 52 | } 53 | 54 | setRouters(app: Koa): void { 55 | Router.routerMap.forEach((_, RouterClass) => new RouterClass()) 56 | 57 | Router.routerSet.forEach(Func => Func()) 58 | 59 | app.use(Router.koaRouter.routes()) 60 | 61 | app.use(ctx => { 62 | ctx.res.setHeader('Access-Control-Allow-Origin', ctx.request.header.origin || '*') 63 | ctx.res.setHeader('Access-Control-Allow-Credentials', 'true') 64 | ctx.res.setHeader('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS') 65 | ctx.res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With, AUTHORIZATION, X-Socket-Id') 66 | }) 67 | } 68 | 69 | private decorator(config: RouterConfig): Function { 70 | if (typeof config === 'string') { 71 | return function(target: any) { 72 | Router.routerMap.set(target, config) 73 | } 74 | } else { 75 | return function(target: any, _key: string, desc: PropertyDescriptor) { 76 | let path = config['path'] 77 | const method = config['method'] 78 | Router.routerSet.add(() => { 79 | const constructor = target.constructor 80 | const parentPath = Router.routerMap.get(constructor) 81 | if (typeof parentPath !== 'undefined') { 82 | path = parentPath + path 83 | } 84 | Router.koaRouter[method](path, async (ctx: KoaRouter.IRouterContext, next: any) => { 85 | let result: any 86 | try { 87 | result = await desc.value.call(target, ctx, next) 88 | } catch (e) { 89 | console.error(e) 90 | ctx.throw(400, e) 91 | } 92 | return result 93 | }) 94 | }) 95 | } 96 | } 97 | } 98 | } 99 | 100 | export default new Router 101 | -------------------------------------------------------------------------------- /server/utils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | const os = require('os') 3 | 4 | const FINGERMARK = JSON.stringify(require('os').networkInterfaces()) 5 | const TIMESTAMP = 1485878400000 6 | const PERIOD = 30 * 24 * 3600 * 1000 7 | let keyCount = 0 8 | 9 | interface FileMeta { 10 | fileName: string 11 | fileSize: number, 12 | chunkSize: number 13 | chunks: number 14 | created: Date 15 | fileMD5: string 16 | lastUpdated: string 17 | } 18 | 19 | export function genKey (fileMeta: Partial, sampleFileBuffer: Buffer) { 20 | const now = Date.now() 21 | const metaBuf = new Buffer(FINGERMARK + (++keyCount) + JSON.stringify(fileMeta) + now) 22 | const time = Math.floor((now - TIMESTAMP) / PERIOD).toString(36) 23 | const key = '0' + time 24 | return key + crypto.createHash('md5').update(metaBuf).update(sampleFileBuffer).digest('hex') 25 | } 26 | -------------------------------------------------------------------------------- /src/FileUploader.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subscriber, Subject } from 'rxjs' 2 | // spark-md5 没有第三方 .d.ts 文件,这里用 commonjs 风格的 require 它 3 | // 如果未再 tsconfig.json 中设置 noImplicitAny: true 且 TypeScript 版本大于 2.1 则也可以用 4 | // import * as SparkMD5 from 'spark-md5' 的方式引用 5 | const SparkMD5 = require('spark-md5') 6 | // @warn memory leak 7 | const $attachment = document.querySelector('.attachment') 8 | const $progressBar = document.querySelector('.progress-bar') as HTMLElement 9 | const apiHost = 'http://127.0.0.1:5000/api' 10 | 11 | interface FileInfo { 12 | fileSize: number 13 | fileMD5: string 14 | lastUpdated: string 15 | fileName: string 16 | } 17 | 18 | interface ChunkMeta { 19 | fileSize: number 20 | chunkSize: number 21 | chunks: number 22 | fileKey: string 23 | } 24 | 25 | type Action = 'pause' | 'resume' | 'progress' | 'complete' 26 | 27 | export class FileUploader { 28 | 29 | private file$ = Observable.fromEvent($attachment, 'change') 30 | .map((r: Event) => (r.target as HTMLInputElement).files[0]) 31 | .filter(f => !!f) 32 | 33 | private click$ = Observable.fromEvent($attachment, 'click') 34 | .map((e: Event) => e.target) 35 | .filter((e: HTMLElement) => e === $attachment) 36 | .scan((acc: number, val: HTMLElement) => { 37 | if (val.classList.contains('glyphicon-paperclip')) { 38 | return 1 39 | } 40 | if (acc === 2) { 41 | return 3 42 | } 43 | return 2 44 | }, 3) 45 | .filter(v => v !== 1) 46 | .do((v) => { 47 | if (v === 2) { 48 | this.action$.next({ name: 'pause' }) 49 | $attachment.classList.remove('glyphicon-pause') 50 | $attachment.classList.add('glyphicon-play') 51 | } else { 52 | this.action$.next({ name: 'resume' }) 53 | this.buildPauseIcon() 54 | } 55 | }) 56 | .map(v => ({ action: v === 2 ? 'PAUSE' : 'RESUME', payload: null })) 57 | 58 | private action$ = new Subject<{ 59 | name: Action 60 | payload?: any 61 | }>() 62 | 63 | private pause$ = this.action$.filter(ac => ac.name === 'pause') 64 | private resume$ = this.action$.filter(ac => ac.name === 'resume') 65 | 66 | private progress$ = this.action$ 67 | .filter(action => action.name === 'progress') 68 | .map(action => action.payload) 69 | .distinctUntilChanged((x: number, y: number) => x - y >= 0) 70 | .do((r: number) => { 71 | const percent = Math.round(r * 100) 72 | $progressBar.style.width = `${percent}%` 73 | $progressBar.firstElementChild.textContent = `${percent > 1 ? percent - 1 : percent} %` 74 | }) 75 | .map(r => ({ action: 'PROGRESS', payload: r })) 76 | 77 | uploadStream$ = this.file$ 78 | .switchMap(this.readFileInfo) 79 | .switchMap(i => Observable.ajax 80 | .post(`${apiHost}/upload/chunk`, i.fileinfo) 81 | .map((r) => { 82 | const blobs = this.slice(i.file, r.response.chunks, r.response.chunkSize) 83 | return { blobs, chunkMeta: r.response, file: i.file } 84 | }) 85 | ) 86 | .do(() => this.buildPauseIcon()) 87 | .switchMap(({ blobs, chunkMeta, file }) => { 88 | const uploaded: number[] = [] 89 | const dists = blobs.map((blob, index) => { 90 | let currentLoaded = 0 91 | return this.uploadChunk(chunkMeta, index, blob) 92 | .do(r => { 93 | currentLoaded = r.loaded / file.size 94 | uploaded[index] = currentLoaded 95 | const percent = uploaded.reduce((acc, val) => acc + (val ? val : 0)) 96 | this.action$.next({ name: 'progress', payload: percent }) 97 | }) 98 | }) 99 | 100 | const uploadStream = Observable.from(dists) 101 | .mergeAll(this.concurrency) 102 | 103 | return Observable.forkJoin(uploadStream) 104 | .mapTo(chunkMeta) 105 | }) 106 | .switchMap((r: ChunkMeta) => Observable.ajax.post(`${apiHost}/upload/chunk/${r.fileKey}`) 107 | .mapTo({ 108 | action: 'UPLOAD_SUCCESS', 109 | payload: r 110 | }) 111 | ) 112 | .do(() => { 113 | $progressBar.firstElementChild.textContent = '100 %' 114 | // restore icon 115 | $attachment.classList.remove('glyphicon-pause') 116 | $attachment.classList.add('glyphicon-paperclip'); 117 | ($attachment.firstElementChild as HTMLInputElement).disabled = false 118 | }) 119 | .merge(this.progress$, this.click$) 120 | 121 | constructor( 122 | private concurrency = 3 123 | ) { } 124 | 125 | // side effect 126 | private buildPauseIcon() { 127 | $attachment.classList.remove('glyphicon-paperclip') 128 | $attachment.classList.add('glyphicon-pause'); 129 | ($attachment.firstElementChild as HTMLInputElement).disabled = true 130 | } 131 | 132 | private readFileInfo(file: File): Observable<{ file: File, fileinfo: FileInfo }> { 133 | const reader = new FileReader() 134 | const spark = new SparkMD5.ArrayBuffer() 135 | reader.readAsArrayBuffer(file) 136 | return Observable.create((observer: Subscriber<{ file: File, fileinfo: FileInfo }>) => { 137 | reader.onload = (e: Event) => { 138 | spark.append((e.target as FileReader).result) 139 | const fileMD5 = spark.end() 140 | observer.next({ 141 | file, fileinfo: { 142 | fileMD5, fileSize: file.size, 143 | lastUpdated: file.lastModifiedDate.toISOString(), 144 | fileName: file.name 145 | } 146 | }) 147 | observer.complete() 148 | } 149 | return () => { 150 | if (!reader.result) { 151 | console.warn('read file aborted') 152 | reader.abort() 153 | } 154 | } 155 | }) 156 | } 157 | 158 | private slice(file: File, n: number, chunkSize: number): Blob[] { 159 | const result: Blob[] = [] 160 | for (let i = 0; i < n; i ++) { 161 | const startSize = i * chunkSize 162 | const slice = file.slice(startSize, i === n - 1 ? startSize + (file.size - startSize) : (i + 1) * chunkSize) 163 | result.push(slice) 164 | } 165 | return result 166 | } 167 | 168 | private uploadChunk(meta: ChunkMeta, index: number, blob: Blob): Observable { 169 | const host = `${apiHost}/upload/chunk/${meta.fileKey}?chunk=${index + 1}&chunks=${meta.chunks}` 170 | return Observable.create((subscriber: Subscriber) => { 171 | const ajax$ = Observable.ajax({ 172 | url: host, 173 | body: blob, 174 | method: 'post', 175 | crossDomain: true, 176 | headers: { 'Content-Type': 'application/octet-stream' }, 177 | progressSubscriber: subscriber 178 | }) 179 | .takeUntil(this.pause$) 180 | .repeatWhen(() => this.resume$) 181 | const subscription = ajax$.subscribe() 182 | return () => subscription.unsubscribe() 183 | }) 184 | .retryWhen(() => this.resume$) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer, Subject } from 'rxjs' 2 | import { 3 | createTodoItem, 4 | mockToggle, 5 | mockHttpPost, 6 | search, 7 | HttpResponse 8 | } from './lib' 9 | import { FileUploader } from './FileUploader' 10 | 11 | const $input = document.querySelector('.todo-val') 12 | const $list = document.querySelector('.list-group') 13 | const $add = document.querySelector('.button-add') 14 | 15 | const type$ = Observable.fromEvent($input, 'keydown') 16 | .publish() 17 | .refCount() 18 | 19 | const enter$ = type$ 20 | .filter(r => r.keyCode === 13) 21 | 22 | const clickAdd$ = Observable.fromEvent($add, 'click') 23 | 24 | const input$ = enter$.merge(clickAdd$) 25 | 26 | const clearInputSubject$ = new Subject() 27 | 28 | const item$ = input$ 29 | .map(() => $input.value) 30 | .filter(r => r !== '') 31 | .distinct(null, clearInputSubject$) 32 | .switchMap(mockHttpPost) 33 | .map(createTodoItem) 34 | .do((ele: HTMLLIElement) => { 35 | $list.appendChild(ele) 36 | $input.value = '' 37 | clearInputSubject$.next() 38 | }) 39 | .publishReplay(1) 40 | .refCount() 41 | 42 | const toggle$ = item$.mergeMap($todoItem => { 43 | return Observable.fromEvent($todoItem, 'click') 44 | .debounceTime(300) 45 | .filter(e => e.target === $todoItem) 46 | .mapTo({ 47 | data: { 48 | _id: $todoItem.dataset['id'], 49 | isDone: $todoItem.classList.contains('done') 50 | }, $todoItem 51 | }) 52 | }) 53 | .switchMap(result => { 54 | return mockToggle(result.data._id, result.data.isDone) 55 | .mapTo(result.$todoItem) 56 | }) 57 | .do(($todoItem: HTMLElement) => { 58 | if ($todoItem.classList.contains('done')) { 59 | $todoItem.classList.remove('done') 60 | } else { 61 | $todoItem.classList.add('done') 62 | } 63 | }) 64 | 65 | const remove$ = item$.mergeMap($todoItem => { 66 | const $removeButton = $todoItem.querySelector('.button-remove') 67 | return Observable.fromEvent($removeButton, 'click') 68 | .mapTo($todoItem) 69 | }) 70 | .do(($todoItem: HTMLElement) => { 71 | // 从 DOM 上移掉 todo item 72 | const $parent = $todoItem.parentNode 73 | $parent.removeChild($todoItem) 74 | }) 75 | 76 | const search$ = type$.debounceTime(200) 77 | .filter(evt => evt.keyCode !== 13) 78 | .map(result => (result.target).value) 79 | .switchMap(search) 80 | .do((result: HttpResponse | null) => { 81 | const actived = document.querySelectorAll('.active') 82 | Array.prototype.forEach.call(actived, (item: HTMLElement) => { 83 | item.classList.remove('active') 84 | }) 85 | if (result) { 86 | const item = document.querySelector(`.todo-item-${result._id}`) 87 | item.classList.add('active') 88 | } 89 | }) 90 | 91 | const uploader = new FileUploader() 92 | 93 | const app$ = toggle$.merge(remove$, search$, uploader.uploadStream$) 94 | .do(r => { 95 | console.log(r) 96 | }) 97 | 98 | app$.subscribe() 99 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RxJS TodoMVC 6 | 7 | 8 |
9 |

Output

10 |
11 | 12 |
13 | 16 |
Add
17 |
18 |
19 |
20 |
21 | 0% 22 |
23 |
24 |
    25 |
    26 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer } from 'rxjs' 2 | 3 | let dbIndex = 0 4 | const searchStorage = new Map() 5 | 6 | export interface HttpResponse { 7 | _id: number 8 | value: string 9 | isDone: boolean 10 | } 11 | 12 | const random = (begin: number, end: number) => { 13 | return begin + Math.floor((end - begin) * Math.random()) + 1 14 | } 15 | 16 | export const search = (inputValue: string): Observable => { 17 | return Observable.create((observer: Observer) => { 18 | let status = 'pending' 19 | const timmer = setTimeout(() => { 20 | let result: HttpResponse = null 21 | for (const [key, data] of searchStorage) { 22 | if (data.value === inputValue) { 23 | result = data 24 | break 25 | } 26 | } 27 | status = 'done' 28 | observer.next(result) 29 | observer.complete() 30 | }, random(400, 1200)) 31 | return () => { 32 | clearTimeout(timmer) 33 | if (status === 'pending') { 34 | console.warn('search canceled') 35 | } 36 | } 37 | }) 38 | } 39 | 40 | export const mockHttpPost = (value: string): Observable => { 41 | return Observable.create((observer: Observer) => { 42 | let status = 'pending' 43 | const timmer = setTimeout(() => { 44 | const result = { 45 | _id: ++dbIndex, value, 46 | isDone: false 47 | } 48 | searchStorage.set(result._id, result) 49 | status = 'done' 50 | observer.next(result) 51 | observer.complete() 52 | }, random(10, 1000)) 53 | return () => { 54 | clearTimeout(timmer) 55 | if (status === 'pending') { 56 | console.warn('post canceled') 57 | } 58 | } 59 | }) 60 | } 61 | 62 | export const mockToggle = (id: string, isDone: boolean): Observable => { 63 | return Observable.create((observer: Observer) => { 64 | let status = 'pending' 65 | const timmer = setTimeout(() => { 66 | const result = searchStorage.get(parseInt(id)) 67 | result.isDone = !isDone 68 | searchStorage.set(result._id, result) 69 | status = 'done' 70 | observer.next(result) 71 | observer.complete() 72 | }, random(10, 1000)) 73 | return () => { 74 | clearTimeout(timmer) 75 | if (status === 'pending') { 76 | console.warn('post canceled') 77 | } 78 | } 79 | }) 80 | } 81 | 82 | export const mockDelete = (id: number): Observable => { 83 | return Observable.create((observer: Observer) => { 84 | let status = 'pending' 85 | const timmer = setTimeout(() => { 86 | searchStorage.delete(id) 87 | status = 'done' 88 | observer.next(true) 89 | observer.complete() 90 | }, random(10, 1000)) 91 | return () => { 92 | clearTimeout(timmer) 93 | if (status === 'pending') { 94 | console.warn('delete canceled') 95 | } 96 | } 97 | }) 98 | } 99 | 100 | export const createTodoItem = (data: HttpResponse) => { 101 | const result = document.createElement('LI') 102 | result.classList.add('list-group-item', `todo-item-${data._id}`) 103 | result.setAttribute('data-id', `${data._id}`) 104 | const innerHTML = ` 105 | ${data.value} 106 | 109 | ` 110 | result.innerHTML = innerHTML 111 | return result 112 | } 113 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'jquery' 2 | import 'bootstrap' 3 | import './app.ts' 4 | require('bootstrap.min.css') 5 | require('./style.css') 6 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .list-group { 2 | margin-top: 20px; 3 | user-select: none; 4 | } 5 | .done { 6 | text-decoration: line-through; 7 | } 8 | .button-remove { 9 | position: absolute; 10 | right: 5px; 11 | bottom: 3px; 12 | } 13 | .active { 14 | background: #03a9f4; 15 | } 16 | 17 | .attachment { 18 | top: 0; 19 | } 20 | 21 | .progress-bar > span { 22 | color: black; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "sourceMap": true, 5 | "module": "commonjs", 6 | "experimentalDecorators": true, 7 | "moduleResolution": "node", 8 | "outDir": "lib", 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "noImplicitReturns": true, 12 | "noUnusedParameters": true, 13 | "suppressImplicitAnyIndexErrors": true, 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "types": [ 18 | "node", 19 | "jquery" 20 | ] 21 | }, 22 | "exclude": [ 23 | "node_modules" 24 | ], 25 | "compileOnSave": false, 26 | "awesomeTypescriptLoaderOptions": { 27 | "useCache": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": ["node_modules/tslint-eslint-rules/dist/rules"], 3 | "rules": { 4 | "curly": true, 5 | "eofline": true, 6 | "align": [true, "parameters"], 7 | "class-name": true, 8 | "indent": [true, 2], 9 | "max-line-length": [true, 150], 10 | "no-consecutive-blank-lines": true, 11 | "no-trailing-whitespace": true, 12 | "no-duplicate-variable": true, 13 | "no-console": [true, "trace"], 14 | "no-construct": true, 15 | "no-debugger": true, 16 | "no-var-keyword": true, 17 | "no-empty": true, 18 | "no-unused-expression": true, 19 | "no-eval": true, 20 | "no-use-before-declare": true, 21 | "no-var-requires": false, 22 | "no-require-imports": false, 23 | "no-shadowed-variable": true, 24 | "object-curly-spacing": true, 25 | "one-line": [true, 26 | "check-else", 27 | "check-whitespace", 28 | "check-open-brace"], 29 | "prefer-const": true, 30 | "quotemark": [true, 31 | "single", 32 | "avoid-escape"], 33 | "semicolon": [true, "never"], 34 | "ter-prefer-arrow-callback": true, 35 | "typedef-whitespace": [true, { 36 | "call-signature": "nospace", 37 | "index-signature": "nospace", 38 | "parameter": "nospace", 39 | "property-declaration": "nospace", 40 | "variable-declaration": "nospace" 41 | }], 42 | "use-isnan": true, 43 | "whitespace": [true, 44 | "check-branch", 45 | "check-decl", 46 | "check-operator", 47 | "check-separator", 48 | "check-type"], 49 | "comment-format": [true, "check-space", "check-lowercase"] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const { CheckerPlugin } = require('awesome-typescript-loader') 5 | 6 | // Webpack Config 7 | const webpackConfig = { 8 | entry: { 9 | 'main': './src/main.ts' 10 | }, 11 | 12 | output: { 13 | filename: '[name].js', 14 | path: path.join(__dirname, 'dist'), 15 | publicPath: '/' 16 | }, 17 | 18 | plugins: [ 19 | new CheckerPlugin(), 20 | new webpack.HotModuleReplacementPlugin(), 21 | new HtmlWebpackPlugin({ 22 | filename: 'index.html', 23 | template: 'src/index.html', 24 | inject: true 25 | }), 26 | new webpack.ProvidePlugin({ 27 | jQuery: 'jquery', 28 | $: 'jquery' 29 | }) 30 | ], 31 | 32 | mode: 'development', 33 | 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.ts?$/, 38 | exclude: /node_modules/, 39 | loader: 'tslint-loader', 40 | options: { 41 | typeCheck: true 42 | }, 43 | enforce: 'pre', 44 | }, 45 | { 46 | test: /\.js$/, 47 | loader: 'source-map-loader', 48 | include: /rxjs/, 49 | enforce: 'pre', 50 | }, 51 | { 52 | test: /\.ts$/, 53 | use: 'awesome-typescript-loader' 54 | }, 55 | { 56 | test: /\.css$/, 57 | loaders: [ 58 | 'style-loader', 59 | 'css-loader', 60 | ] 61 | }, 62 | { 63 | test: /\.(eot|svg|ttf|woff|woff2)$/, 64 | use: 'file-loader' 65 | }, 66 | { 67 | test: /\.html$/, 68 | use: 'raw-loader' 69 | } 70 | ] 71 | } 72 | 73 | } 74 | 75 | const defaultConfig = { 76 | devtool: 'cheap-module-source-map', 77 | cache: true, 78 | 79 | resolve: { 80 | modules: [ path.join(__dirname, 'src'), 'node_modules' ], 81 | extensions: ['.ts', '.js'], 82 | alias: { 83 | 'bootstrap.min.css': path.join(process.cwd(), 'node_modules/bootstrap/dist/css/bootstrap.min.css') 84 | } 85 | }, 86 | 87 | devServer: { 88 | historyApiFallback: true, 89 | watchOptions: { aggregateTimeout: 300, poll: 1000 } 90 | }, 91 | 92 | node: { 93 | global: true, 94 | crypto: false, 95 | module: false, 96 | Buffer: false, 97 | clearImmediate: false, 98 | setImmediate: false 99 | } 100 | } 101 | 102 | const webpackMerge = require('webpack-merge') 103 | module.exports = webpackMerge(defaultConfig, webpackConfig) --------------------------------------------------------------------------------