├── .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 | [](https://opencollective.com/learning-rxjs) [](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 |
19 |
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)
--------------------------------------------------------------------------------