From 7b15f3e0d8cf1e55cba0074253512b984473cf46 Mon Sep 17 00:00:00 2001 From: wizardforcel Date: Thu, 16 May 2024 12:30:07 +0800 Subject: [PATCH] 2024-05-16 12:30:04 --- CNAME | 2 +- NAV.md | 2 +- README.md | 13 +- docs/begin-react/0.md | 119 - docs/begin-react/1.md | 423 --- docs/begin-react/2.md | 819 ----- docs/begin-react/3.md | 783 ---- docs/begin-react/README.md | 37 - docs/begin-react/SUMMARY.md | 5 - docs/full-stk-react-proj/00.md | 117 - docs/full-stk-react-proj/01.md | 171 - docs/full-stk-react-proj/02.md | 601 ---- docs/full-stk-react-proj/03.md | 1125 ------ docs/full-stk-react-proj/04.md | 1609 --------- docs/full-stk-react-proj/05.md | 1806 ---------- docs/full-stk-react-proj/06.md | 1737 --------- docs/full-stk-react-proj/07.md | 1590 --------- docs/full-stk-react-proj/08.md | 1014 ------ docs/full-stk-react-proj/09.md | 998 ------ docs/full-stk-react-proj/10.md | 1050 ------ docs/full-stk-react-proj/11.md | 1352 ------- docs/full-stk-react-proj/12.md | 471 --- docs/full-stk-react-proj/README.md | 37 - docs/full-stk-react-proj/SUMMARY.md | 14 - docs/get-start-rvr/00.md | 116 - docs/get-start-rvr/01.md | 416 --- docs/get-start-rvr/02.md | 255 -- docs/get-start-rvr/03.md | 351 -- docs/get-start-rvr/04.md | 596 ---- docs/get-start-rvr/05.md | 588 ---- docs/get-start-rvr/06.md | 257 -- docs/get-start-rvr/07.md | 516 --- docs/get-start-rvr/08.md | 523 --- docs/get-start-rvr/09.md | 522 --- docs/get-start-rvr/10.md | 447 --- docs/get-start-rvr/11.md | 598 ---- docs/get-start-rvr/12.md | 586 --- docs/get-start-rvr/README.md | 37 - docs/get-start-rvr/SUMMARY.md | 14 - docs/handson-design-ptn-rn/00.md | 100 - docs/handson-design-ptn-rn/01.md | 807 ----- docs/handson-design-ptn-rn/02.md | 1116 ------ docs/handson-design-ptn-rn/03.md | 1098 ------ docs/handson-design-ptn-rn/04.md | 395 --- docs/handson-design-ptn-rn/05.md | 777 ---- docs/handson-design-ptn-rn/06.md | 698 ---- docs/handson-design-ptn-rn/07.md | 674 ---- docs/handson-design-ptn-rn/08.md | 567 --- docs/handson-design-ptn-rn/09.md | 650 ---- docs/handson-design-ptn-rn/10.md | 623 ---- docs/handson-design-ptn-rn/11.md | 654 ---- docs/handson-design-ptn-rn/README.md | 37 - docs/handson-design-ptn-rn/SUMMARY.md | 13 - docs/learn-react-hook/00.md | 130 - docs/learn-react-hook/01.md | 912 ----- docs/learn-react-hook/02.md | 541 --- docs/learn-react-hook/03.md | 1028 ------ docs/learn-react-hook/04.md | 822 ----- docs/learn-react-hook/05.md | 777 ---- docs/learn-react-hook/06.md | 993 ------ docs/learn-react-hook/07.md | 621 ---- docs/learn-react-hook/08.md | 1238 ------- docs/learn-react-hook/09.md | 235 -- docs/learn-react-hook/10.md | 1711 --------- docs/learn-react-hook/11.md | 1384 -------- docs/learn-react-hook/12.md | 1300 ------- docs/learn-react-hook/13.md | 970 ----- docs/learn-react-hook/14.md | 306 -- docs/learn-react-hook/README.md | 37 - docs/learn-react-hook/SUMMARY.md | 19 - docs/learn-react-hook/sec1.md | 9 - docs/learn-react-hook/sec2.md | 13 - docs/learn-react-hook/sec3.md | 9 - docs/learn-react-ts3/00.md | 150 - docs/learn-react-ts3/01.md | 1931 ---------- docs/learn-react-ts3/02.md | 979 ----- docs/learn-react-ts3/03.md | 1885 ---------- docs/learn-react-ts3/04.md | 1456 -------- docs/learn-react-ts3/05.md | 998 ------ docs/learn-react-ts3/06.md | 1234 ------- docs/learn-react-ts3/07.md | 1595 --------- docs/learn-react-ts3/08.md | 1396 -------- docs/learn-react-ts3/09.md | 1470 -------- docs/learn-react-ts3/10.md | 1424 -------- docs/learn-react-ts3/11.md | 981 ------ docs/learn-react-ts3/12.md | 1014 ------ docs/learn-react-ts3/README.md | 37 - docs/learn-react-ts3/SUMMARY.md | 14 - docs/learn-web-dev-react-boots/00.md | 151 - docs/learn-web-dev-react-boots/01.md | 503 --- docs/learn-web-dev-react-boots/02.md | 772 ---- docs/learn-web-dev-react-boots/03.md | 674 ---- docs/learn-web-dev-react-boots/04.md | 612 ---- docs/learn-web-dev-react-boots/05.md | 643 ---- docs/learn-web-dev-react-boots/06.md | 608 ---- docs/learn-web-dev-react-boots/07.md | 686 ---- docs/learn-web-dev-react-boots/08.md | 727 ---- docs/learn-web-dev-react-boots/09.md | 956 ----- docs/learn-web-dev-react-boots/10.md | 650 ---- docs/learn-web-dev-react-boots/README.md | 37 - docs/learn-web-dev-react-boots/SUMMARY.md | 12 - docs/lightn-fast-mobi-app-dev-galiio/00.md | 100 - docs/lightn-fast-mobi-app-dev-galiio/01.md | 457 --- docs/lightn-fast-mobi-app-dev-galiio/02.md | 476 --- docs/lightn-fast-mobi-app-dev-galiio/03.md | 258 -- docs/lightn-fast-mobi-app-dev-galiio/04.md | 367 -- docs/lightn-fast-mobi-app-dev-galiio/05.md | 269 -- docs/lightn-fast-mobi-app-dev-galiio/06.md | 259 -- docs/lightn-fast-mobi-app-dev-galiio/07.md | 291 -- docs/lightn-fast-mobi-app-dev-galiio/08.md | 348 -- docs/lightn-fast-mobi-app-dev-galiio/09.md | 282 -- docs/lightn-fast-mobi-app-dev-galiio/10.md | 407 --- docs/lightn-fast-mobi-app-dev-galiio/11.md | 518 --- docs/lightn-fast-mobi-app-dev-galiio/12.md | 229 -- .../lightn-fast-mobi-app-dev-galiio/README.md | 37 - .../SUMMARY.md | 14 - docs/master-full-stk-react-web-dev/0.md | 132 - docs/master-full-stk-react-web-dev/1.md | 1838 ---------- docs/master-full-stk-react-web-dev/2.md | 1696 --------- docs/master-full-stk-react-web-dev/3.md | 566 --- docs/master-full-stk-react-web-dev/4.md | 2274 ------------ docs/master-full-stk-react-web-dev/5.md | 1959 ----------- docs/master-full-stk-react-web-dev/6.md | 1825 ---------- docs/master-full-stk-react-web-dev/7.md | 137 - docs/master-full-stk-react-web-dev/8.md | 1040 ------ docs/master-full-stk-react-web-dev/9.md | 369 -- docs/master-full-stk-react-web-dev/README.md | 37 - docs/master-full-stk-react-web-dev/SUMMARY.md | 11 - docs/mobx-quick-start-guide/0.md | 125 - docs/mobx-quick-start-guide/1.md | 234 -- docs/mobx-quick-start-guide/2.md | 724 ---- docs/mobx-quick-start-guide/3.md | 454 --- docs/mobx-quick-start-guide/4.md | 592 ---- docs/mobx-quick-start-guide/5.md | 742 ---- docs/mobx-quick-start-guide/6.md | 1056 ------ docs/mobx-quick-start-guide/7.md | 567 --- docs/mobx-quick-start-guide/8.md | 730 ---- docs/mobx-quick-start-guide/9.md | 473 --- docs/mobx-quick-start-guide/README.md | 37 - docs/mobx-quick-start-guide/SUMMARY.md | 11 - docs/prog-web-app-react/00.md | 129 - docs/prog-web-app-react/01.md | 520 --- docs/prog-web-app-react/02.md | 865 ----- docs/prog-web-app-react/03.md | 546 --- docs/prog-web-app-react/04.md | 536 --- docs/prog-web-app-react/05.md | 784 ----- docs/prog-web-app-react/06.md | 1227 ------- docs/prog-web-app-react/07.md | 295 -- docs/prog-web-app-react/08.md | 792 ----- docs/prog-web-app-react/09.md | 500 --- docs/prog-web-app-react/10.md | 322 -- docs/prog-web-app-react/11.md | 480 --- docs/prog-web-app-react/12.md | 412 --- docs/prog-web-app-react/13.md | 267 -- docs/prog-web-app-react/14.md | 378 -- docs/prog-web-app-react/README.md | 37 - docs/prog-web-app-react/SUMMARY.md | 16 - docs/react-cb/00.md | 94 - docs/react-cb/01.md | 496 --- docs/react-cb/02.md | 3134 ----------------- docs/react-cb/03.md | 1379 -------- docs/react-cb/04.md | 631 ---- docs/react-cb/05.md | 1411 -------- docs/react-cb/06.md | 558 --- docs/react-cb/07.md | 632 ---- docs/react-cb/08.md | 1775 ---------- docs/react-cb/09.md | 1422 -------- docs/react-cb/10.md | 1537 -------- docs/react-cb/11.md | 1399 -------- docs/react-cb/12.md | 833 ----- docs/react-cb/13.md | 494 --- docs/react-cb/14.md | 933 ----- docs/react-cb/15.md | 36 - docs/react-cb/README.md | 37 - docs/react-cb/SUMMARY.md | 17 - docs/react-cross-plat-app-dev-rn/0.md | 32 - docs/react-cross-plat-app-dev-rn/1.md | 1125 ------ docs/react-cross-plat-app-dev-rn/2.md | 1154 ------ docs/react-cross-plat-app-dev-rn/3.md | 1559 -------- docs/react-cross-plat-app-dev-rn/4.md | 1266 ------- docs/react-cross-plat-app-dev-rn/5.md | 101 - docs/react-cross-plat-app-dev-rn/README.md | 37 - docs/react-cross-plat-app-dev-rn/SUMMARY.md | 7 - docs/react-proj/00.md | 156 - docs/react-proj/01.md | 858 ----- docs/react-proj/02.md | 1327 ------- docs/react-proj/03.md | 978 ----- docs/react-proj/04.md | 1029 ------ docs/react-proj/05.md | 1432 -------- docs/react-proj/06.md | 1104 ------ docs/react-proj/07.md | 1296 ------- docs/react-proj/08.md | 1219 ------- docs/react-proj/09.md | 1053 ------ docs/react-proj/10.md | 1189 ------- docs/react-proj/11.md | 1233 ------- docs/react-proj/12.md | 1026 ------ docs/react-proj/README.md | 37 - docs/react-proj/SUMMARY.md | 14 - docs/react-rn/00.md | 180 - docs/react-rn/01.md | 112 - docs/react-rn/02.md | 515 --- docs/react-rn/03.md | 635 ---- docs/react-rn/04.md | 421 --- docs/react-rn/05.md | 740 ---- docs/react-rn/06.md | 970 ----- docs/react-rn/07.md | 690 ---- docs/react-rn/08.md | 587 --- docs/react-rn/09.md | 843 ----- docs/react-rn/10.md | 587 --- docs/react-rn/11.md | 676 ---- docs/react-rn/12.md | 83 - docs/react-rn/13.md | 283 -- docs/react-rn/14.md | 600 ---- docs/react-rn/15.md | 680 ---- docs/react-rn/16.md | 677 ---- docs/react-rn/17.md | 475 --- docs/react-rn/18.md | 410 --- docs/react-rn/19.md | 658 ---- docs/react-rn/20.md | 847 ----- docs/react-rn/21.md | 421 --- docs/react-rn/22.md | 510 --- docs/react-rn/23.md | 574 --- docs/react-rn/24.md | 574 --- docs/react-rn/25.md | 152 - docs/react-rn/26.md | 555 --- docs/react-rn/README.md | 37 - docs/react-rn/SUMMARY.md | 28 - docs/react-router-quick-start-guide/00.md | 91 - docs/react-router-quick-start-guide/01.md | 364 -- docs/react-router-quick-start-guide/02.md | 529 --- docs/react-router-quick-start-guide/03.md | 489 --- docs/react-router-quick-start-guide/04.md | 426 --- docs/react-router-quick-start-guide/05.md | 519 --- docs/react-router-quick-start-guide/06.md | 617 ---- docs/react-router-quick-start-guide/07.md | 463 --- docs/react-router-quick-start-guide/08.md | 368 -- docs/react-router-quick-start-guide/README.md | 37 - .../react-router-quick-start-guide/SUMMARY.md | 10 - docs/react16-essence/00.md | 162 - docs/react16-essence/01.md | 187 - docs/react16-essence/02.md | 521 --- docs/react16-essence/03.md | 498 --- docs/react16-essence/04.md | 401 --- docs/react16-essence/05.md | 305 -- docs/react16-essence/06.md | 369 -- docs/react16-essence/07.md | 643 ---- docs/react16-essence/08.md | 834 ----- docs/react16-essence/09.md | 1032 ------ docs/react16-essence/10.md | 392 --- docs/react16-essence/11.md | 1201 ------- docs/react16-essence/12.md | 665 ---- docs/react16-essence/README.md | 37 - docs/react16-essence/SUMMARY.md | 14 - docs/react16-tool/00.md | 103 - docs/react16-tool/01.md | 157 - docs/react16-tool/02.md | 211 -- docs/react16-tool/03.md | 427 --- docs/react16-tool/04.md | 852 ----- docs/react16-tool/05.md | 962 ----- docs/react16-tool/06.md | 752 ---- docs/react16-tool/07.md | 770 ---- docs/react16-tool/08.md | 806 ----- docs/react16-tool/09.md | 724 ---- docs/react16-tool/10.md | 804 ----- docs/react16-tool/11.md | 1134 ------ docs/react16-tool/README.md | 37 - docs/react16-tool/SUMMARY.md | 13 - docs/react17-design-ptn-best-prac/00.md | 115 - docs/react17-design-ptn-best-prac/01.md | 562 --- docs/react17-design-ptn-best-prac/02.md | 1184 ------- docs/react17-design-ptn-best-prac/03.md | 941 ----- docs/react17-design-ptn-best-prac/04.md | 472 --- docs/react17-design-ptn-best-prac/05.md | 2086 ----------- docs/react17-design-ptn-best-prac/06.md | 538 --- docs/react17-design-ptn-best-prac/07.md | 627 ---- docs/react17-design-ptn-best-prac/08.md | 1142 ------ docs/react17-design-ptn-best-prac/09.md | 644 ---- docs/react17-design-ptn-best-prac/10.md | 280 -- docs/react17-design-ptn-best-prac/11.md | 383 -- docs/react17-design-ptn-best-prac/12.md | 572 --- docs/react17-design-ptn-best-prac/13.md | 298 -- docs/react17-design-ptn-best-prac/14.md | 526 --- docs/react17-design-ptn-best-prac/15.md | 155 - docs/react17-design-ptn-best-prac/README.md | 37 - docs/react17-design-ptn-best-prac/SUMMARY.md | 20 - docs/react17-design-ptn-best-prac/sec1.md | 8 - docs/react17-design-ptn-best-prac/sec2.md | 11 - docs/react17-design-ptn-best-prac/sec3.md | 14 - docs/rn-blueprint/00.md | 99 - docs/rn-blueprint/01.md | 872 ----- docs/rn-blueprint/02.md | 991 ------ docs/rn-blueprint/03.md | 1103 ------ docs/rn-blueprint/04.md | 1132 ------ docs/rn-blueprint/05.md | 619 ---- docs/rn-blueprint/06.md | 1545 -------- docs/rn-blueprint/07.md | 1249 ------- docs/rn-blueprint/08.md | 1993 ----------- docs/rn-blueprint/README.md | 37 - docs/rn-blueprint/SUMMARY.md | 10 - docs/rn-cb-2e/00.md | 179 - docs/rn-cb-2e/01.md | 257 -- docs/rn-cb-2e/02.md | 1106 ------ docs/rn-cb-2e/03.md | 883 ----- docs/rn-cb-2e/04.md | 1237 ------- docs/rn-cb-2e/05.md | 1398 -------- docs/rn-cb-2e/06.md | 1105 ------ docs/rn-cb-2e/07.md | 1057 ------ docs/rn-cb-2e/08.md | 1452 -------- docs/rn-cb-2e/09.md | 873 ----- docs/rn-cb-2e/10.md | 1314 ------- docs/rn-cb-2e/11.md | 1045 ------ docs/rn-cb-2e/12.md | 1365 ------- docs/rn-cb-2e/13.md | 1614 --------- docs/rn-cb-2e/14.md | 440 --- docs/rn-cb-2e/15.md | 519 --- docs/rn-cb-2e/README.md | 37 - docs/rn-cb-2e/SUMMARY.md | 17 - docs/svrless-web-app-react-fb/0.md | 83 - docs/svrless-web-app-react-fb/1.md | 903 ----- docs/svrless-web-app-react-fb/2.md | 737 ---- docs/svrless-web-app-react-fb/3.md | 723 ---- docs/svrless-web-app-react-fb/4.md | 1202 ------- docs/svrless-web-app-react-fb/5.md | 931 ----- docs/svrless-web-app-react-fb/6.md | 394 --- docs/svrless-web-app-react-fb/7.md | 711 ---- docs/svrless-web-app-react-fb/8.md | 813 ----- docs/svrless-web-app-react-fb/9.md | 492 --- docs/svrless-web-app-react-fb/README.md | 37 - docs/svrless-web-app-react-fb/SUMMARY.md | 11 - index.html | 4 +- 330 files changed, 11 insertions(+), 209312 deletions(-) delete mode 100644 docs/begin-react/0.md delete mode 100644 docs/begin-react/1.md delete mode 100644 docs/begin-react/2.md delete mode 100644 docs/begin-react/3.md delete mode 100644 docs/begin-react/README.md delete mode 100644 docs/begin-react/SUMMARY.md delete mode 100644 docs/full-stk-react-proj/00.md delete mode 100644 docs/full-stk-react-proj/01.md delete mode 100644 docs/full-stk-react-proj/02.md delete mode 100644 docs/full-stk-react-proj/03.md delete mode 100644 docs/full-stk-react-proj/04.md delete mode 100644 docs/full-stk-react-proj/05.md delete mode 100644 docs/full-stk-react-proj/06.md delete mode 100644 docs/full-stk-react-proj/07.md delete mode 100644 docs/full-stk-react-proj/08.md delete mode 100644 docs/full-stk-react-proj/09.md delete mode 100644 docs/full-stk-react-proj/10.md delete mode 100644 docs/full-stk-react-proj/11.md delete mode 100644 docs/full-stk-react-proj/12.md delete mode 100644 docs/full-stk-react-proj/README.md delete mode 100644 docs/full-stk-react-proj/SUMMARY.md delete mode 100644 docs/get-start-rvr/00.md delete mode 100644 docs/get-start-rvr/01.md delete mode 100644 docs/get-start-rvr/02.md delete mode 100644 docs/get-start-rvr/03.md delete mode 100644 docs/get-start-rvr/04.md delete mode 100644 docs/get-start-rvr/05.md delete mode 100644 docs/get-start-rvr/06.md delete mode 100644 docs/get-start-rvr/07.md delete mode 100644 docs/get-start-rvr/08.md delete mode 100644 docs/get-start-rvr/09.md delete mode 100644 docs/get-start-rvr/10.md delete mode 100644 docs/get-start-rvr/11.md delete mode 100644 docs/get-start-rvr/12.md delete mode 100644 docs/get-start-rvr/README.md delete mode 100644 docs/get-start-rvr/SUMMARY.md delete mode 100644 docs/handson-design-ptn-rn/00.md delete mode 100644 docs/handson-design-ptn-rn/01.md delete mode 100644 docs/handson-design-ptn-rn/02.md delete mode 100644 docs/handson-design-ptn-rn/03.md delete mode 100644 docs/handson-design-ptn-rn/04.md delete mode 100644 docs/handson-design-ptn-rn/05.md delete mode 100644 docs/handson-design-ptn-rn/06.md delete mode 100644 docs/handson-design-ptn-rn/07.md delete mode 100644 docs/handson-design-ptn-rn/08.md delete mode 100644 docs/handson-design-ptn-rn/09.md delete mode 100644 docs/handson-design-ptn-rn/10.md delete mode 100644 docs/handson-design-ptn-rn/11.md delete mode 100644 docs/handson-design-ptn-rn/README.md delete mode 100644 docs/handson-design-ptn-rn/SUMMARY.md delete mode 100644 docs/learn-react-hook/00.md delete mode 100644 docs/learn-react-hook/01.md delete mode 100644 docs/learn-react-hook/02.md delete mode 100644 docs/learn-react-hook/03.md delete mode 100644 docs/learn-react-hook/04.md delete mode 100644 docs/learn-react-hook/05.md delete mode 100644 docs/learn-react-hook/06.md delete mode 100644 docs/learn-react-hook/07.md delete mode 100644 docs/learn-react-hook/08.md delete mode 100644 docs/learn-react-hook/09.md delete mode 100644 docs/learn-react-hook/10.md delete mode 100644 docs/learn-react-hook/11.md delete mode 100644 docs/learn-react-hook/12.md delete mode 100644 docs/learn-react-hook/13.md delete mode 100644 docs/learn-react-hook/14.md delete mode 100644 docs/learn-react-hook/README.md delete mode 100644 docs/learn-react-hook/SUMMARY.md delete mode 100644 docs/learn-react-hook/sec1.md delete mode 100644 docs/learn-react-hook/sec2.md delete mode 100644 docs/learn-react-hook/sec3.md delete mode 100644 docs/learn-react-ts3/00.md delete mode 100644 docs/learn-react-ts3/01.md delete mode 100644 docs/learn-react-ts3/02.md delete mode 100644 docs/learn-react-ts3/03.md delete mode 100644 docs/learn-react-ts3/04.md delete mode 100644 docs/learn-react-ts3/05.md delete mode 100644 docs/learn-react-ts3/06.md delete mode 100644 docs/learn-react-ts3/07.md delete mode 100644 docs/learn-react-ts3/08.md delete mode 100644 docs/learn-react-ts3/09.md delete mode 100644 docs/learn-react-ts3/10.md delete mode 100644 docs/learn-react-ts3/11.md delete mode 100644 docs/learn-react-ts3/12.md delete mode 100644 docs/learn-react-ts3/README.md delete mode 100644 docs/learn-react-ts3/SUMMARY.md delete mode 100644 docs/learn-web-dev-react-boots/00.md delete mode 100644 docs/learn-web-dev-react-boots/01.md delete mode 100644 docs/learn-web-dev-react-boots/02.md delete mode 100644 docs/learn-web-dev-react-boots/03.md delete mode 100644 docs/learn-web-dev-react-boots/04.md delete mode 100644 docs/learn-web-dev-react-boots/05.md delete mode 100644 docs/learn-web-dev-react-boots/06.md delete mode 100644 docs/learn-web-dev-react-boots/07.md delete mode 100644 docs/learn-web-dev-react-boots/08.md delete mode 100644 docs/learn-web-dev-react-boots/09.md delete mode 100644 docs/learn-web-dev-react-boots/10.md delete mode 100644 docs/learn-web-dev-react-boots/README.md delete mode 100644 docs/learn-web-dev-react-boots/SUMMARY.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/00.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/01.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/02.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/03.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/04.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/05.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/06.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/07.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/08.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/09.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/10.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/11.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/12.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/README.md delete mode 100644 docs/lightn-fast-mobi-app-dev-galiio/SUMMARY.md delete mode 100644 docs/master-full-stk-react-web-dev/0.md delete mode 100644 docs/master-full-stk-react-web-dev/1.md delete mode 100644 docs/master-full-stk-react-web-dev/2.md delete mode 100644 docs/master-full-stk-react-web-dev/3.md delete mode 100644 docs/master-full-stk-react-web-dev/4.md delete mode 100644 docs/master-full-stk-react-web-dev/5.md delete mode 100644 docs/master-full-stk-react-web-dev/6.md delete mode 100644 docs/master-full-stk-react-web-dev/7.md delete mode 100644 docs/master-full-stk-react-web-dev/8.md delete mode 100644 docs/master-full-stk-react-web-dev/9.md delete mode 100644 docs/master-full-stk-react-web-dev/README.md delete mode 100644 docs/master-full-stk-react-web-dev/SUMMARY.md delete mode 100644 docs/mobx-quick-start-guide/0.md delete mode 100644 docs/mobx-quick-start-guide/1.md delete mode 100644 docs/mobx-quick-start-guide/2.md delete mode 100644 docs/mobx-quick-start-guide/3.md delete mode 100644 docs/mobx-quick-start-guide/4.md delete mode 100644 docs/mobx-quick-start-guide/5.md delete mode 100644 docs/mobx-quick-start-guide/6.md delete mode 100644 docs/mobx-quick-start-guide/7.md delete mode 100644 docs/mobx-quick-start-guide/8.md delete mode 100644 docs/mobx-quick-start-guide/9.md delete mode 100644 docs/mobx-quick-start-guide/README.md delete mode 100644 docs/mobx-quick-start-guide/SUMMARY.md delete mode 100644 docs/prog-web-app-react/00.md delete mode 100644 docs/prog-web-app-react/01.md delete mode 100644 docs/prog-web-app-react/02.md delete mode 100644 docs/prog-web-app-react/03.md delete mode 100644 docs/prog-web-app-react/04.md delete mode 100644 docs/prog-web-app-react/05.md delete mode 100644 docs/prog-web-app-react/06.md delete mode 100644 docs/prog-web-app-react/07.md delete mode 100644 docs/prog-web-app-react/08.md delete mode 100644 docs/prog-web-app-react/09.md delete mode 100644 docs/prog-web-app-react/10.md delete mode 100644 docs/prog-web-app-react/11.md delete mode 100644 docs/prog-web-app-react/12.md delete mode 100644 docs/prog-web-app-react/13.md delete mode 100644 docs/prog-web-app-react/14.md delete mode 100644 docs/prog-web-app-react/README.md delete mode 100644 docs/prog-web-app-react/SUMMARY.md delete mode 100644 docs/react-cb/00.md delete mode 100644 docs/react-cb/01.md delete mode 100644 docs/react-cb/02.md delete mode 100644 docs/react-cb/03.md delete mode 100644 docs/react-cb/04.md delete mode 100644 docs/react-cb/05.md delete mode 100644 docs/react-cb/06.md delete mode 100644 docs/react-cb/07.md delete mode 100644 docs/react-cb/08.md delete mode 100644 docs/react-cb/09.md delete mode 100644 docs/react-cb/10.md delete mode 100644 docs/react-cb/11.md delete mode 100644 docs/react-cb/12.md delete mode 100644 docs/react-cb/13.md delete mode 100644 docs/react-cb/14.md delete mode 100644 docs/react-cb/15.md delete mode 100644 docs/react-cb/README.md delete mode 100644 docs/react-cb/SUMMARY.md delete mode 100644 docs/react-cross-plat-app-dev-rn/0.md delete mode 100644 docs/react-cross-plat-app-dev-rn/1.md delete mode 100644 docs/react-cross-plat-app-dev-rn/2.md delete mode 100644 docs/react-cross-plat-app-dev-rn/3.md delete mode 100644 docs/react-cross-plat-app-dev-rn/4.md delete mode 100644 docs/react-cross-plat-app-dev-rn/5.md delete mode 100644 docs/react-cross-plat-app-dev-rn/README.md delete mode 100644 docs/react-cross-plat-app-dev-rn/SUMMARY.md delete mode 100644 docs/react-proj/00.md delete mode 100644 docs/react-proj/01.md delete mode 100644 docs/react-proj/02.md delete mode 100644 docs/react-proj/03.md delete mode 100644 docs/react-proj/04.md delete mode 100644 docs/react-proj/05.md delete mode 100644 docs/react-proj/06.md delete mode 100644 docs/react-proj/07.md delete mode 100644 docs/react-proj/08.md delete mode 100644 docs/react-proj/09.md delete mode 100644 docs/react-proj/10.md delete mode 100644 docs/react-proj/11.md delete mode 100644 docs/react-proj/12.md delete mode 100644 docs/react-proj/README.md delete mode 100644 docs/react-proj/SUMMARY.md delete mode 100644 docs/react-rn/00.md delete mode 100644 docs/react-rn/01.md delete mode 100644 docs/react-rn/02.md delete mode 100644 docs/react-rn/03.md delete mode 100644 docs/react-rn/04.md delete mode 100644 docs/react-rn/05.md delete mode 100644 docs/react-rn/06.md delete mode 100644 docs/react-rn/07.md delete mode 100644 docs/react-rn/08.md delete mode 100644 docs/react-rn/09.md delete mode 100644 docs/react-rn/10.md delete mode 100644 docs/react-rn/11.md delete mode 100644 docs/react-rn/12.md delete mode 100644 docs/react-rn/13.md delete mode 100644 docs/react-rn/14.md delete mode 100644 docs/react-rn/15.md delete mode 100644 docs/react-rn/16.md delete mode 100644 docs/react-rn/17.md delete mode 100644 docs/react-rn/18.md delete mode 100644 docs/react-rn/19.md delete mode 100644 docs/react-rn/20.md delete mode 100644 docs/react-rn/21.md delete mode 100644 docs/react-rn/22.md delete mode 100644 docs/react-rn/23.md delete mode 100644 docs/react-rn/24.md delete mode 100644 docs/react-rn/25.md delete mode 100644 docs/react-rn/26.md delete mode 100644 docs/react-rn/README.md delete mode 100644 docs/react-rn/SUMMARY.md delete mode 100644 docs/react-router-quick-start-guide/00.md delete mode 100644 docs/react-router-quick-start-guide/01.md delete mode 100644 docs/react-router-quick-start-guide/02.md delete mode 100644 docs/react-router-quick-start-guide/03.md delete mode 100644 docs/react-router-quick-start-guide/04.md delete mode 100644 docs/react-router-quick-start-guide/05.md delete mode 100644 docs/react-router-quick-start-guide/06.md delete mode 100644 docs/react-router-quick-start-guide/07.md delete mode 100644 docs/react-router-quick-start-guide/08.md delete mode 100644 docs/react-router-quick-start-guide/README.md delete mode 100644 docs/react-router-quick-start-guide/SUMMARY.md delete mode 100644 docs/react16-essence/00.md delete mode 100644 docs/react16-essence/01.md delete mode 100644 docs/react16-essence/02.md delete mode 100644 docs/react16-essence/03.md delete mode 100644 docs/react16-essence/04.md delete mode 100644 docs/react16-essence/05.md delete mode 100644 docs/react16-essence/06.md delete mode 100644 docs/react16-essence/07.md delete mode 100644 docs/react16-essence/08.md delete mode 100644 docs/react16-essence/09.md delete mode 100644 docs/react16-essence/10.md delete mode 100644 docs/react16-essence/11.md delete mode 100644 docs/react16-essence/12.md delete mode 100644 docs/react16-essence/README.md delete mode 100644 docs/react16-essence/SUMMARY.md delete mode 100644 docs/react16-tool/00.md delete mode 100644 docs/react16-tool/01.md delete mode 100644 docs/react16-tool/02.md delete mode 100644 docs/react16-tool/03.md delete mode 100644 docs/react16-tool/04.md delete mode 100644 docs/react16-tool/05.md delete mode 100644 docs/react16-tool/06.md delete mode 100644 docs/react16-tool/07.md delete mode 100644 docs/react16-tool/08.md delete mode 100644 docs/react16-tool/09.md delete mode 100644 docs/react16-tool/10.md delete mode 100644 docs/react16-tool/11.md delete mode 100644 docs/react16-tool/README.md delete mode 100644 docs/react16-tool/SUMMARY.md delete mode 100644 docs/react17-design-ptn-best-prac/00.md delete mode 100644 docs/react17-design-ptn-best-prac/01.md delete mode 100644 docs/react17-design-ptn-best-prac/02.md delete mode 100644 docs/react17-design-ptn-best-prac/03.md delete mode 100644 docs/react17-design-ptn-best-prac/04.md delete mode 100644 docs/react17-design-ptn-best-prac/05.md delete mode 100644 docs/react17-design-ptn-best-prac/06.md delete mode 100644 docs/react17-design-ptn-best-prac/07.md delete mode 100644 docs/react17-design-ptn-best-prac/08.md delete mode 100644 docs/react17-design-ptn-best-prac/09.md delete mode 100644 docs/react17-design-ptn-best-prac/10.md delete mode 100644 docs/react17-design-ptn-best-prac/11.md delete mode 100644 docs/react17-design-ptn-best-prac/12.md delete mode 100644 docs/react17-design-ptn-best-prac/13.md delete mode 100644 docs/react17-design-ptn-best-prac/14.md delete mode 100644 docs/react17-design-ptn-best-prac/15.md delete mode 100644 docs/react17-design-ptn-best-prac/README.md delete mode 100644 docs/react17-design-ptn-best-prac/SUMMARY.md delete mode 100644 docs/react17-design-ptn-best-prac/sec1.md delete mode 100644 docs/react17-design-ptn-best-prac/sec2.md delete mode 100644 docs/react17-design-ptn-best-prac/sec3.md delete mode 100644 docs/rn-blueprint/00.md delete mode 100644 docs/rn-blueprint/01.md delete mode 100644 docs/rn-blueprint/02.md delete mode 100644 docs/rn-blueprint/03.md delete mode 100644 docs/rn-blueprint/04.md delete mode 100644 docs/rn-blueprint/05.md delete mode 100644 docs/rn-blueprint/06.md delete mode 100644 docs/rn-blueprint/07.md delete mode 100644 docs/rn-blueprint/08.md delete mode 100644 docs/rn-blueprint/README.md delete mode 100644 docs/rn-blueprint/SUMMARY.md delete mode 100644 docs/rn-cb-2e/00.md delete mode 100644 docs/rn-cb-2e/01.md delete mode 100644 docs/rn-cb-2e/02.md delete mode 100644 docs/rn-cb-2e/03.md delete mode 100644 docs/rn-cb-2e/04.md delete mode 100644 docs/rn-cb-2e/05.md delete mode 100644 docs/rn-cb-2e/06.md delete mode 100644 docs/rn-cb-2e/07.md delete mode 100644 docs/rn-cb-2e/08.md delete mode 100644 docs/rn-cb-2e/09.md delete mode 100644 docs/rn-cb-2e/10.md delete mode 100644 docs/rn-cb-2e/11.md delete mode 100644 docs/rn-cb-2e/12.md delete mode 100644 docs/rn-cb-2e/13.md delete mode 100644 docs/rn-cb-2e/14.md delete mode 100644 docs/rn-cb-2e/15.md delete mode 100644 docs/rn-cb-2e/README.md delete mode 100644 docs/rn-cb-2e/SUMMARY.md delete mode 100644 docs/svrless-web-app-react-fb/0.md delete mode 100644 docs/svrless-web-app-react-fb/1.md delete mode 100644 docs/svrless-web-app-react-fb/2.md delete mode 100644 docs/svrless-web-app-react-fb/3.md delete mode 100644 docs/svrless-web-app-react-fb/4.md delete mode 100644 docs/svrless-web-app-react-fb/5.md delete mode 100644 docs/svrless-web-app-react-fb/6.md delete mode 100644 docs/svrless-web-app-react-fb/7.md delete mode 100644 docs/svrless-web-app-react-fb/8.md delete mode 100644 docs/svrless-web-app-react-fb/9.md delete mode 100644 docs/svrless-web-app-react-fb/README.md delete mode 100644 docs/svrless-web-app-react-fb/SUMMARY.md diff --git a/CNAME b/CNAME index bcef2c1..9f67099 100644 --- a/CNAME +++ b/CNAME @@ -1 +1 @@ -react.flygon.net \ No newline at end of file +flrct.flygon.net \ No newline at end of file diff --git a/NAV.md b/NAV.md index 3e5d9bf..9001e6a 100644 --- a/NAV.md +++ b/NAV.md @@ -62,7 +62,7 @@ + [GeeksForGeeks HTML 中文教程📚](https://opendoccn.github.io/geeksforgeeks-html-zh) + [飞龙的 Vue 译文集📚](https://opendoccn.github.io/opendoccn-vue-zh) + [飞龙的 Angular 译文集📚](https://opendoccn.github.io/opendoccn-angular-zh) - + [飞龙的 React 译文集📚](https://opendoccn.github.io/opendoccn-react-zh) + + [FreeLearning React 译文集📚](https://opendoccn.github.io/opendoccn-react-zh) + [飞龙的 jQuery 译文集📚](https://opendoccn.github.io/opendoccn-jquery-zh) + [飞龙的 jQuery 译文集(二)📚](https://opendoccn.github.io/opendoccn-jquery-zh-pt2) + 后端/大数据 diff --git a/README.md b/README.md index a98a14b..2b90803 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ -# 飞龙的 React 译文集 +# FreeLearning React 译文集 > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 只有经历过地狱般的磨练,才能拥有创造天堂的力量。——泰戈尔 -* [在线阅读](https://react.flygon.net) +* [在线阅读](https://flrct.flygon.net) + ## 下载 ### Docker ``` -docker pull apachecn0/flygon-react-zh -docker run -tid -p :80 apachecn0/flygon-react-zh +docker pull apachecn0/freelearn-react-zh +docker run -tid -p :80 apachecn0/freelearn-react-zh # 访问 http://localhost:{port} 查看文档 ``` ### NPM ``` -npm install -g flygon-react-zh -flygon-react-zh +npm install -g freelearn-react-zh +freelearn-react-zh # 访问 http://localhost:{port} 查看文档 ``` diff --git a/docs/begin-react/0.md b/docs/begin-react/0.md deleted file mode 100644 index bccfd61..0000000 --- a/docs/begin-react/0.md +++ /dev/null @@ -1,119 +0,0 @@ -# 零、前言 - -Angular 和 React 等项目正在迅速改变开发团队构建 web 应用程序并将其部署到生产环境的方式。在这本书中,您将学习使用 React 和应对现实世界项目和挑战所需的基础知识。这本书包含了如何在开发过程中考虑关键用户需求的有益指导,还展示了如何使用诸如状态管理、数据绑定、路由和流行的组件标记(JSX)等高级概念。当您完成所包含的示例时,您会发现自己已经准备好进入真实世界的个人或专业前端项目。 - -完成本书后,您将能够: - -* 了解 React 如何在更广泛的应用程序堆栈中工作 -* 分析如何将标准接口分解为特定组件 -* 使用 HTML 或 JSX 成功创建您自己日益复杂的 React 组件 -* 正确处理多个用户事件及其对整个应用程序状态的影响 -* 了解组件生命周期以优化应用程序的用户体验 -* 配置路由,以便轻松直观地导航组件 - -# 这本书是给谁的 - -如果您是一名前端开发人员,希望用 JavaScript 创建真正的反应式用户界面,那么这本书适合您。为了做出反应,您需要在 JavaScript 语言的基础上建立坚实的基础,包括在 ES2015 中引入的新 OOP 特征。假设理解 HTML 和 CSS,并且 Node.js 的基本知识在管理开发工作流的上下文中是有用的,但不是必需的。 - -# 这本书涵盖的内容 - -[第一章](1.html)*介绍 React 和 UI 设计*,介绍 React 并帮助我们开始构建基于 React 的应用程序的基础架构。然后,我们将分析如何设计一个用户界面,以便它可以很容易地映射到组件。 - -[第 2 章](2.html)*创建组件*教我们如何实现 React 组件,如何将多个组件组装成一个组件,以及如何管理其内部状态。我们将通过构建一个简单的应用程序来探索 React 组件的实现。 - -[第 3 章](3.html)*管理用户交互*教我们如何管理用户与基于 React 的用户界面组件交互产生的事件。我们将探索 React 组件生命周期中触发的事件,并将学习如何利用这些事件来创建高效组件。 - -# 充分利用这本书 - -本手册要求系统具备以下最低硬件要求: - -* 处理器:奔腾 4(或同等产品) -* 4 GB 内存 -* 硬盘空间:10 GB -* 因特网连接 - -还应安装以下软件: - -* 任何现代操作系统(最好是 Windows 10 版本 1507) -* Node.js 的最新版本([https://nodejs.org/en/](https://nodejs.org/en/) ) -* 任何现代浏览器的最新版本(最好是 Chrome) - -# 下载示例代码文件 - -您可以从您的账户[www.packtpub.com](http://www.packtpub.com)下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问[www.packtpub.com/support](http://www.packtpub.com/support)并注册,将文件通过电子邮件直接发送给您。 - -您可以通过以下步骤下载代码文件: - -1. 登录或注册[www.packtpub.com](http://www.packtpub.com/support)。 -2. 选择“支持”选项卡。 -3. 点击代码下载和勘误表。 -4. 在搜索框中输入图书名称,然后按照屏幕上的说明进行操作。 - -下载文件后,请确保使用以下最新版本解压或解压缩文件夹: - -* WinRAR/7-Zip for Windows -* 适用于 Mac 的 Zipeg/iZip/UnRarX -* 适用于 Linux 的 7-Zip/PeaZip - -该书的代码包也托管在 GitHub 上的[https://github.com/TrainingByPackt/Beginning-React](https://github.com/TrainingByPackt/Beginning-React) 。如果代码有更新,它将在现有 GitHub 存储库中更新。 - -我们的丰富书籍和视频目录中还有其他代码包,请访问**[https://github.com/PacktPublishing/](https://github.com/PacktPublishing/)** 。看看他们! - -# 下载彩色图像 - -我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:[https://www.packtpub.com/sites/default/files/downloads/BeginningReact_ColorImages.pdf](https://www.packtpub.com/sites/default/files/downloads/BeginningReact_ColorImages.pdf) 。 - -# 使用的惯例 - -本书中使用了许多文本约定。 - -`CodeInText`:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这里有一个例子:“通过包装`App`组件,`BrowserRouter`组件丰富了它的路由功能。” - -代码块设置如下: - -```jsx -class Catalog extends React.Component { - constructor() { - super(); -``` - -当我们希望提请您注意代码块的特定部分时,相关行或项目以粗体显示: - -```jsx -import { BrowserRouter } from 'react-router-dom' -ReactDOM.render( - - - - , document.getElementById('root')); -``` - -任何命令行输入或输出的编写方式如下: - -```jsx -create-react-app --version -``` - -**粗体**:表示一个新术语、一个重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词出现在文本中,如下所示。下面是一个示例:“现在我们需要创建一个视图来显示**目录**组件或**关于**页面。” - -**活动**:这些是基于情景的活动,可以让你在整个课程中实际应用所学知识。它们通常是在现实世界问题或情况的背景下出现的。 - -Warnings or important notes appear like this. - -# 联系 - -我们欢迎读者的反馈。 - -**一般反馈**:发送电子邮件`feedback@packtpub.com`并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至`questions@packtpub.com`。 - -**勘误表**:尽管我们已尽一切努力确保内容的准确性,但还是会出现错误。如果您在本书中发现错误,如果您能向我们报告,我们将不胜感激。请访问[www.packtpub.com/submit-errata](http://www.packtpub.com/submit-errata),选择您的书籍,点击 errata 提交表单链接,然后输入详细信息。 - -**盗版**:如果您在互联网上发现我们作品的任何形式的非法复制品,请您提供我们的位置地址或网站名称,我们将不胜感激。请通过`copyright@packtpub.com`与我们联系,并提供该材料的链接。 - -**如果您有兴趣成为一名作家**:如果您对某个主题有专业知识,并且您有兴趣撰写或贡献一本书,请访问[authors.packtpub.com](http://authors.packtpub.com/)。 - -# 评论 - -请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在读者可以看到并使用您的无偏见意见做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。非常感谢。 - -有关 Packt 的更多信息,请访问[packtpub.com](https://www.packtpub.com/)。 \ No newline at end of file diff --git a/docs/begin-react/1.md b/docs/begin-react/1.md deleted file mode 100644 index 616a7c7..0000000 --- a/docs/begin-react/1.md +++ /dev/null @@ -1,423 +0,0 @@ -# 一、React 和 UI 设计简介 - -React 无疑是网络上谈论最多的图书馆之一。它的流行程度与 jQuery 的鼎盛时期一样,越来越多的开发人员选择它来构建 web 应用程序的用户界面。为什么它变得如此流行?为什么这个 JavaScript 库与其他库相比如此创新? - -在这本书中,我们将通过展示图书馆提供的内容,并通过使用它来构建高效的 web 用户界面,试图为这些问题提供答案。 - -在本章中,我们将介绍 React,并开始构建基于 React 的应用程序的基本基础架构。然后,我们将分析如何设计一个用户界面,以便它可以很容易地映射到 React 组件,从而充分利用 React 内部架构的优点。 - -在本章结束时,您将能够: - -* 描述 React 是什么以及它在应用程序开发中的适用性 -* 设置基于 React 的应用程序的基础结构 -* 设计应用程序的 UI,优化它以便在 React 中使用 - -# 什么是反应? - -简单地说,React 是一个用于构建可组合用户界面的 JavaScript 库。这意味着我们可以通过组合名为**组件**的项来构建用户界面。组件是有助于构建用户界面的元素。它可以是一个文本框、一个按钮、一个完整的表单、一组其他组件,等等。甚至整个应用程序的用户界面也是一个组件。因此,React 鼓励创建组件来构建用户界面;如果这些组件是可重用的,那就更好了。 - -React 组件能够显示随时间变化的数据,并且当我们遵循一些指导原则时,这些变化数据的可视化是自动的。 - -由于该库涉及用户界面,您可能想知道哪些表示设计模式是受以下启发的:**模型视图控制器**、**模型视图演示器**、**模型视图模型**或其他东西启发的。React 不受特定呈现模式的约束。React 实现了最常见模式的*视图*部分,让开发人员自由选择最佳方法来实现模型、演示者以及构建应用程序所需的一切。这方面很重要,因为它允许我们将其分类为库,而不是框架;因此,与 Angular 等框架进行比较可能会发现一些不一致之处。 - -# 如何设置基于 React 的应用程序 - -React 是一个 JavaScript 库,因此我们应该能够通过 HTML 页面中的` - - ` -} -``` - -当服务器收到对根 URL 的请求时,该 HTML 模板将在浏览器中呈现,ID 为`"root"`的`div`元素将包含我们的`React`组件。 - -接下来,创建一个`client`文件夹,我们将在其中添加两个 React 文件`main.js`和`HelloWorld.js`。 - -`main.js`文件只是呈现 HTML 文档中`div`元素中的顶级条目`React`组件。 - -`mern-simplesetup/client/main.js`: - -```jsx -import React from 'react' -import { render } from 'react-dom' -import HelloWorld from './HelloWorld' - -render(, document.getElementById('root')) -``` - -在这种情况下,条目`React`组件是从`HelloWorld.js`导入的`HelloWorld`组件。 - -`HelloWorld.js`包含一个基本的`HelloWorld`组件,该组件热导出以在开发过程中启用`react-hot-loader`的热重新加载。 - -`mern-simplesetup/client/HelloWorld.js`: - -```jsx -import React, { Component } from 'react' -import { hot } from 'react-hot-loader' - -class HelloWorld extends Component { - render() { - return ( -
-

Hello World!

-
- ) - } -} - -export default hot(module)(HelloWorld) -``` - -要在服务器收到对根 URL 的请求时查看浏览器中呈现的`React`组件,我们需要使用 Webpack 和 Babel 设置来编译和绑定此代码,并添加服务器端代码,用绑定的代码响应根路由请求。 - -# 带有 Express 和 Node 的服务器 - -在项目文件夹中,创建一个名为`server`的文件夹,并添加一个名为`server.js`的文件,用于设置服务器。然后,添加另一个名为`devBundle.js`的文件,该文件将帮助在开发模式下使用 Webpack 配置编译 React 代码 - -# 快速应用 - -在`server.js`中,我们将首先添加代码导入`express`模块,以初始化 Express app。 - -`mern-simplesetup/server/server.js`: - -```jsx -import express from 'express' - -const app = express() -``` - -然后,我们将使用此 Express 应用构建节点服务器应用的其余部分。 - -# 在开发过程中捆绑应用 - -为了简化开发流程,我们将在服务器运行时初始化 Webpack 以编译客户端代码。在`devBundle.js`中,我们将设置一个编译方法,将 Express app 配置为使用 Webpack 中间件编译、捆绑和服务代码,并在开发模式下启用热重新加载。 - -`mern-simplesetup/server/devBundle.js`: - -```jsx -import webpack from 'webpack' -import webpackMiddleware from 'webpack-dev-middleware' -import webpackHotMiddleware from 'webpack-hot-middleware' -import webpackConfig from './../webpack.config.client.js' - -const compile = (app) => { - if(process.env.NODE_ENV == "development"){ - const compiler = webpack(webpackConfig) - const middleware = webpackMiddleware(compiler, { - publicPath: webpackConfig.output.publicPath - }) - app.use(middleware) - app.use(webpackHotMiddleware(compiler)) - } -} - -export default { - compile -} -``` - -我们将在`server.js`中通过在开发模式下添加以下行来调用此编译方法。 - -`mern-simplesetup/server/server.js`: - -```jsx -import devBundle from './devBundle' -const app = express() -devBundle.compile(app) - -``` - -这两条突出显示的行仅用于开发模式,在为生产构建应用代码时应注释掉。在开发模式下,当这些行被执行时,Webpack 将编译并捆绑 React 代码以将其放置在`dist/bundle.js`中。 - -# 为 dist 文件夹中的静态文件提供服务 - -Webpack 将在开发和生产模式下编译客户端代码,然后将捆绑的文件放在`dist`文件夹中。为了使这些静态文件在客户端请求时可用,我们将在`server.js`中添加以下代码来服务`dist/folder`中的静态文件。 - -`mern-simplesetup/server/server.js`: - -```jsx -import path from 'path' -const CURRENT_WORKING_DIR = process.cwd() -app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist'))) -``` - -# 在根目录下呈现模板 - -当服务器收到根 URL`/`的请求时,我们将在浏览器中呈现`template.js`。在`server.js`中,将以下路线处理代码添加到 Express app,以在`/`接收 GET 请求。 - -`mern-simplesetup/server/server.js`: - -```jsx -import template from './../template' -app.get('/', (req, res) => { - res.status(200).send(template()) -}) -``` - -最后,添加服务器代码以在指定端口上侦听传入请求。 - -`mern-simplesetup/server/server.js`: - -```jsx -let port = process.env.PORT || 3000 -app.listen(port, function onStart(err) { - if (err) { - console.log(err) - } - console.info('Server started on port %s.', port) -}) -``` - -# 将服务器连接到 MongoDB - -要将您的节点服务器连接到 MongoDB,请将以下代码添加到`server.js`,并确保您的工作区中运行了 MongoDB。 - -`mern-simplesetup/server/server.js`: - -```jsx -import { MongoClient } from 'mongodb' -const url = process.env.MONGODB_URI || 'mongodb://localhost:27017/mernSimpleSetup' -MongoClient.connect(url, (err, db)=>{ - console.log("Connected successfully to mongodb server") - db.close() -}) -``` - -在此代码示例中,`MongoClient`是使用`url`连接到正在运行的`MongoDB`实例的驱动程序,允许我们在后端实现与数据库相关的代码。 - -# npm 运行脚本 - -更新`package.json`文件,为开发和生产添加以下 npm 运行脚本。 - -`mern-simplesetup/package.json`: - -```jsx -"scripts": { - "development": "nodemon", - "build": "webpack --config webpack.config.client.production.js -``` - -```jsx - && webpack --mode=production --config - webpack.config.server.js", - "start": "NODE_ENV=production node ./dist/server.generated.js" -} -``` - -* `npm run development`:此命令将获取 Nodemon、Webpack,并启动服务器进行开发 -* `npm run build`:这将为生产模式生成客户端和服务器代码包(在运行此脚本之前,请确保从`server.js`中删除`devBundle.compile`代码) -* `npm run start`:此命令将在生产中运行捆绑代码 - -# 实时开发与调试 - -要运行迄今为止开发的代码,并确保一切正常,您可以执行以下步骤: - -1. **从命令行**:`npm run development`运行应用。 -2. **在浏览器中加载**:在浏览器中打开根 URL,如果您使用的是本地机器设置,则为`http://localhost:3000`。您应该会看到一个标题为 MERN Kickstart 的页面,它只显示 Hello World!。 -3. **开发代码并现场调试**:将`HelloWorld.js`组件文本`'Hello World!'`更改为`'hello'`。保存更改以在浏览器中查看即时更新,同时检查命令行输出以查看`bundle.js`是否未重新创建。类似地,您还可以在更改服务器端代码时看到即时更新,从而提高开发过程中的生产率。 - -如果您已经做到了这一点,恭喜您,您已经准备好开始开发激动人心的 MERN 应用了。 - -# 总结 - -在本章中,我们讨论了开发工具选项以及如何安装 MERN 技术,然后编写代码检查开发环境是否正确设置。 - -我们首先研究了适合 web 开发的推荐工作区、IDE、版本控制软件和浏览器选项。作为开发人员,您可以根据自己的偏好从这些选项中进行选择。 - -接下来,我们通过首先安装 MongoDB、Node 和 npm,然后使用 npm 添加其余必需的库来设置 MERN 堆栈技术。 - -在继续编写代码检查此设置之前,我们配置了 Webpack 和 Babel,以便在开发过程中编译和捆绑代码,并构建可用于生产的代码。我们了解到,在浏览器上打开应用之前,有必要编译用于开发 MERN 应用的 ES6 和 JSX 代码。 - -此外,我们通过将 React Hot Loader 用于前端开发、配置 Nodemon 用于后端开发以及在开发期间运行服务器时在一个命令中编译客户端和服务器代码,使开发流程更加高效。 - -在下一章中,我们将使用此设置开始构建一个框架 MERN 应用,该应用将作为全功能应用的基础。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/03.md b/docs/full-stk-react-proj/03.md deleted file mode 100644 index c31dcbb..0000000 --- a/docs/full-stk-react-proj/03.md +++ /dev/null @@ -1,1125 +0,0 @@ -# 三、使用 MongoDB、Express 和 Node 构建后端 - -在大多数 web 应用的开发过程中,有一些常见任务、基本特性和实现代码在整个过程中重复出现。本书中开发的 MERN 应用也是如此。考虑到这些相似性,我们将首先为框架 MERN 应用奠定基础,该应用可以轻松修改和扩展以实现各种 MERN 应用。 - -在本章中,我们将介绍以下主题,并使用 Node、Express 和 MongoDB 从 MERN 骨架的后端实现开始: - -* MERN 应用中的用户 CRUD 和 auth -* 使用 Express 服务器处理 HTTP 请求 -* 对用户模型使用 Mongoose 模式 -* 用于用户 CRUD 和 auth 的 API -* 使用 JWT 对受保护的路由进行身份验证 -* 运行后端代码和检查 API - -# 框架应用概述 - -框架应用将封装基本功能和大多数 MERN 应用重复的工作流。我们将基本上构建一个基本但功能完整的 MERN web 应用框架,用户为**cr**eate、**u**pdate、**d**elete(**CRUD**)和**auth**诱骗——**认证**来源(**认证**)功能,还将展示如何为使用此堆栈构建的通用 web 应用开发、组织和运行代码。目的是使框架尽可能简单,以便易于扩展,并可作为开发不同 MERN 应用的基础应用。 - -# 特征分解 - -在框架应用中,我们将添加以下具有用户 CRUD 和身份验证功能实现的用例: - -* **注册**:用户可以使用电子邮件地址创建新帐户进行注册 -* **用户列表**:任何访问者都可以看到所有注册用户的列表 -* **认证**:注册用户可以登录和注销 -* **受保护用户档案**:只有注册用户才能在登录后查看个人用户详细信息 -* **授权用户编辑和删除**:只有注册和认证的用户才能编辑或删除自己的用户帐户详细信息 - -# 本章的重点–后端 - -在本章中,我们将重点介绍使用 Node、Express 和 MongoDB 为框架应用构建一个工作后端。完成的后端将是一个独立的服务器端应用,可以处理 HTTP 请求以创建用户、列出所有用户以及查看、更新或删除数据库中的用户,同时考虑用户身份验证和授权。 - -# 用户模型 - -用户模型将定义要存储在 MongoDB 数据库中的用户详细信息,并处理与用户相关的业务逻辑,如密码加密和用户数据验证。此骨架版本的用户模型将是基本的,支持以下属性: - -| **字段名** | **型** | **说明** | -| `name` | 一串 | 存储用户名所需的字段 | -| `email` | 一串 | 存储用户电子邮件和识别每个帐户所需的唯一字段(每个唯一电子邮件只允许一个帐户) | -| `password` | 一串 | 身份验证的必填字段,数据库将存储加密的密码,而不是出于安全目的存储的实际字符串 | -| `created` | 日期 | 创建新用户帐户时自动生成的时间戳 | -| `updated` | 日期 | 更新现有用户详细信息时自动生成的时间戳 | - -# 用户 CRUD 的 API 端点 - -为了在用户数据库上启用和处理用户 CRUD 操作,后端将实现并公开前端可以在视图中使用的 API 端点,如下所示: - -| **操作** | **API 路线** | **HTTP 方式** | -| 创建用户 | `/api/users` | `POST` | -| 列出所有用户 | `/api/users` | `GET` | -| 获取用户 | `/api/users/:userId` | `GET` | -| 更新用户 | `/api/users/:userId` | `PUT` | -| 删除用户 | `/api/users/:userId` | `DELETE` | -| 用户登录 | `/auth/signin` | `POST` | -| 用户注销(可选) | `/auth/signout` | `GET` | - -其中一些用户 CRUD 操作将具有受保护的访问权限,这将要求请求的客户端进行身份验证、授权或两者兼有。最后两条路由用于身份验证,允许用户登录和注销。 - -# 使用 JSON Web 令牌进行身份验证 - -为了根据框架特性限制和保护对用户 API 端点的访问,后端需要合并身份验证和授权机制。在实现 web 应用的用户身份验证时,有许多选项。最常见且经过时间测试的选项是使用会话在客户端和服务器端存储用户状态。但较新的方法是使用**JSON Web 令牌**(**JWT**)作为无状态身份验证机制,不需要在服务器端存储用户状态。 - -这两种方法对于相关的实际用例都有优势。然而,为了使本书中的代码保持简单,并且因为它与 MERN 堆栈和我们的示例应用很好地匹配,我们将使用 JWT 进行 auth 实现。此外,本书还将在未来章节中建议安全增强选项。 - -# JWT 的工作原理 - -当用户使用其凭据成功登录时,服务器端将生成一个使用密钥和唯一用户详细信息签名的 JWT。然后,该令牌返回给请求客户端,以本地保存在`localStorage`、`sessionStorage`或浏览器中的 cookie 中,基本上将维护用户状态的责任移交给客户端: - -![](img/fd35db0d-22fd-4aa9-8206-3e51089ada4d.png) - -对于成功登录后发出的 HTTP 请求,特别是对受保护且访问受限的 API 端点的请求,客户端必须将此令牌附加到请求。更具体地说,`JSON Web Token`必须作为`Bearer`包含在请求`Authorization`头中: - -```jsx -Authorization: Bearer -``` - -当服务器收到受保护 API 端点的请求时,它会检查请求的`Authorization`头是否有有效的 JWT,然后验证签名以识别发送方,并确保请求数据未损坏。如果令牌有效,则向请求客户端授予对关联操作或资源的访问权限,否则将返回授权错误。 - -在骨架应用中,当用户使用电子邮件和密码登录时,后端将生成一个带有用户 ID 和密钥的签名 JWT,该密钥仅在服务器上可用。当用户试图查看任何用户配置文件、更新其帐户详细信息或删除其用户帐户时,需要使用此令牌进行验证。 - -实现用户模型以存储和验证用户数据,然后将其与 API 集成以基于 auth 和 JWT 执行 CRUD 操作,将生成一个功能独立的后端。在本章的其余部分中,我们将了解如何在 MERN 堆栈和设置中实现这一点。 - -# 实现骨架后端 - -为了开始开发 MERN 框架的后端部分,我们将首先设置项目文件夹,安装和配置必要的 npm 模块,然后准备运行脚本以帮助开发和运行代码。然后,我们将一步一步地完成代码,以实现用户模型、API 端点和基于 JWT 的身份验证,以满足前面为面向用户的特性定义的规范。 - -The code discussed in this chapter, and for the complete skeleton application is available on GitHub in the repository at [github.com/shamahoque/mern-skeleton](https://github.com/shamahoque/mern-skeleton). The code for just the backend is available at the same repository in the branch named `mern-skeleton-backend`. You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -# 文件夹和文件结构 - -以下文件夹结构仅显示与 MERN skeleton 后端相关的文件。使用这些文件,我们将生成一个运行正常的独立服务器端应用: - -```jsx -| mern_skeleton/ - | -- config/ - | --- config.js - | -- server/ - | --- controllers/ - | ---- auth.controller.js - | ---- user.controller.js - | --- helpers/ - | ---- dbErrorHandler.js - | --- models/ - | ---- user.model.js - | --- routes/ - | ---- auth.routes.js - | ---- user.routes.js - | --- express.js - | --- server.js - | -- .babelrc - | -- nodemon.json - | -- package.json - | -- template.js - | -- webpack.config.server.js -``` - -这个结构将在下一章中进一步扩展,我们通过添加一个`React`前端来完成骨架应用。 - -# 建立项目 - -如果开发环境已经设置好,我们可以初始化 MERN 项目来开始开发后端。首先,我们将在项目文件夹中初始化`package.json`,配置并安装开发依赖项,设置代码中要使用的配置变量,并使用运行脚本更新`package.json`,以帮助开发和运行代码 - -# 初始化 package.json - -我们需要一个`package.json`文件来存储项目的元信息,列出模块依赖项和版本号,并定义运行脚本。要初始化项目文件夹中的`package.json`文件,请从命令行转到项目文件夹并运行`npm init`,然后按照说明添加必要的详细信息。创建`package.json`后,我们可以继续进行设置和开发,并在整个代码实现过程中需要更多模块时更新文件。 - -# 开发依赖关系 - -为了开始开发并运行后端服务器代码,我们将按照[第 2 章](02.html)*、**准备开发环境*中所述配置并安装 Babel、Webpack 和 Nodemon,只对后端进行一些小的调整。 - -# 巴别塔 - -因为我们将使用 ES6 编写后端代码,所以我们将配置并安装 Babel 模块来转换 ES6。 - -首先,我们在`.babelrc`文件中为最新的 JS 功能和`babel-preset-env`中未涉及的一些 stage-x 功能配置了 Babel。 - -`mern-skeleton/.babelrc`: - -```jsx -{ - "presets": [ - "env", - "stage-2" - ] -} -``` - -接下来,我们从命令行将 Babel 模块安装为`devDependencies`: - -```jsx -npm install --save-dev babel-core babel-loader babel-preset-env babel-preset-stage-2 -``` - -模块安装完成后,您会注意到`package.json`文件中的`devDependencies`列表已更新。 - -# 网页包 - -我们将需要 Webpack 使用 Babel 编译和捆绑服务器端代码,对于配置,我们可以使用[第 2 章](02.html)中讨论的`webpack.config.server.js`,*准备开发环境*。 - -从命令行运行以下命令来安装`webpack`、`webpack-cli`和`webpack-node-externals`模块: - -```jsx -npm install --save-dev webpack webpack-cli webpack-node-externals -``` - -这将安装网页包模块并更新`package.json`文件。 - -# 诺德蒙 - -为了在开发过程中更新代码时自动重新启动节点服务器,我们将使用 Nodemon 监视服务器代码的更改。我们可以使用[第 2 章](02.html)、*准备开发环境*中讨论的相同安装和配置指南。 - -# 配置变量 - -在`config/config.js`文件中,我们将定义一些与服务器端配置相关的变量,这些变量将在代码中使用,但不应作为最佳实践硬编码,也不应出于安全目的硬编码。 - -`mern-skeleton/config/config.js`: - -```jsx -const config = { - env: process.env.NODE_ENV || 'development', - port: process.env.PORT || 3000, - jwtSecret: process.env.JWT_SECRET || "YOUR_secret_key", - mongoUri: process.env.MONGODB_URI || - process.env.MONGO_HOST || - 'mongodb://' + (process.env.IP || 'localhost') + ':' + - (process.env.MONGO_PORT || '27017') + - '/mernproject' -} - -export default config -``` - -定义的配置变量包括: - -* `env`:区分开发模式和生产模式 -* `port`:为服务器定义监听端口 -* `jwtSecret`:用于签署 JWT 的密钥 -* `mongoUri`:项目 MongoDB 数据库的位置 - -# 运行脚本 - -为了在开发后端代码时运行服务器,我们可以从`package.json`文件中的`npm run development`脚本开始。对于完整的框架应用,我们将使用[第 2 章](02.html)、*准备开发环境*中定义的相同运行脚本。 - -`mern-skeleton/package.json`: - -```jsx -"scripts": { - "development": "nodemon" - } -``` - -`npm run development`:根据`nodemon.js`中的配置,在项目文件夹的命令行中运行此命令基本上会启动 Nodemon。该配置指示 Nodemon 监视服务器文件的更新,并在更新时再次构建文件,然后重新启动服务器,以便更改立即可用。 - -# 准备服务器 - -在本节中,我们将集成 Express、Node 和 MongoDB,以便在开始实现特定于用户的功能之前运行完全配置的服务器。 - -# 配置 Express - -要使用 Express,我们将首先安装 Express,然后在`server/express.js`文件中添加并配置它 - -在命令行中,运行以下命令来安装带有`--save`标志的`express`模块,从而自动更新`package.json`文件: - -```jsx -npm install express --save -``` - -安装 Express 后,我们可以将其导入到`express.js`文件中,根据需要进行配置,并将其提供给应用的其余部分。 - -`mern-skeleton/server/express.js`: - -```jsx -import express from 'express' -const app = express() - /*... configure express ... */ -export default app -``` - -为了正确处理 HTTP 请求和服务响应,我们将使用以下模块配置 Express: - -* `body-parser`:Body 解析中间件,用于处理解析可流化请求对象的复杂性,因此我们可以通过在请求体内交换 JSON 来简化浏览器服务器通信: - * 安装`body-parser`模块:`npm install body-parser --save` - * 配置 Express:`bodyParser.json()`和`bodyParser.urlencoded({ extended: true })` -* `cookie-parser`:Cookie 解析中间件,用于解析和设置请求对象中的 Cookie: - 安装`cookie-parser`模块:`npm install cookie-parser --save` -* `compression`:压缩中间件,将尝试压缩通过中间件的所有请求的响应体: - 安装`compression`模块:`npm install compression --save` -* `helmet`:通过设置各种 HTTP 头帮助保护 Express 应用安全的中间件功能集合: - 安装`helmet`模块:`npm install helmet --save` -* `cors`:启用**CORS**(**跨源资源共享**)的中间件: - 安装`cors`模块:`npm install cors --save` - -在安装了前面的模块之后,我们可以更新`express.js`来导入这些模块并配置 Express app,然后再导出它以用于服务器代码的其余部分。 - -更新后的`mern-skeleton/server/express.js`代码如下: - -```jsx -import express from 'express' -import bodyParser from 'body-parser' -import cookieParser from 'cookie-parser' -import compress from 'compression' -import cors from 'cors' -import helmet from 'helmet' - -const app = express() - -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: true })) -``` - -```jsx -app.use(cookieParser()) -app.use(compress()) -app.use(helmet()) -app.use(cors()) - -export default app -``` - -# 启动服务器 - -通过将 Express 应用配置为接受 HTTP 请求,我们可以继续使用它来实现服务器以侦听传入的请求。 - -在`mern-skeleton/server/server.js`文件中,添加以下代码来实现服务器: - -```jsx -import config from './../config/config' -import app from './express' - -app.listen(config.port, (err) => { - if (err) { - console.log(err) - } - console.info('Server started on port %s.', config.port) -}) -``` - -我们首先导入配置变量以设置服务器将侦听的端口号,然后导入配置的 Express 应用以启动服务器。 - -要运行此代码并继续开发,现在可以从命令行运行`npm run development`。如果代码没有错误,服务器应该在 Nodemon 监视代码更改的情况下开始运行。 - -# 设置 Mongoose 并连接到 MongoDB - -我们将使用`Mongoose`模块来实现此框架中的用户模型,以及我们 MERN 应用的所有未来数据模型。在这里,我们将首先配置 Mongoose,并利用它定义与 MongoDB 数据库的连接。 - -首先,要安装`mongoose`模块,运行以下命令: - -```jsx -npm install mongoose --save -``` - -然后更新`server.js`文件导入`mongoose`模块,配置为使用本机 ES6 承诺,最后处理与项目 MongoDB 数据库的连接。 - -`mern-skeleton/server/server.js`: - -```jsx -import mongoose from 'mongoose' - -mongoose.Promise = global.Promise -mongoose.connect(config.mongoUri) - -mongoose.connection.on('error', () => { - throw new Error(`unable to connect to database: ${mongoUri}`) -}) -``` - -如果代码正在开发中运行,保存此更新应重新启动现在与 Mongoose 和 MongoDB 集成的服务器。 - -Mongoose is a MongoDB object modeling tool that provides a schema-based solution to model application data. It includes built-in type casting, validation, query building, and business logic hooks. Using Mongoose with this backend stack provides a higher layer over MongoDB with more functionality including mapping object models to database documents. Thus, making it simpler and more productive to develop with a Node and MongoDB backend. To learn more about Mongoose, visit [mongoosejs.com](http://mongoosejs.com). - -# 在根 URL 处提供 HTML 模板 - -现在有了一台支持节点、Express 和 MongoDB 的服务器,我们可以对其进行扩展,以响应根 URL`/`处的传入请求,提供 HTML 模板。 - -在`template.js`文件中,添加一个 JS 函数,该函数返回一个简单的 HTML 文档,该文档将在浏览器屏幕上呈现`Hello World`。 - -`mern-skeleton/template.js`: - -```jsx -export default () => { - return ` - - - - MERN Skeleton - - -
Hello World
- - ` -} -``` - -要在根 URL 处提供此模板,请更新`express.js`文件以导入此模板,并在响应`'/'`路由的 GET 请求时发送该模板。 - -`mern-skeleton/server/express.js`: - -```jsx -import Template from './../template' -... -app.get('/', (req, res) => { - res.status(200).send(Template()) -}) -... -``` - -通过此更新,在浏览器中打开根 URL 应该会显示页面上呈现的 Hello World。 - -如果您正在本地计算机上运行代码,则根 URL 将为`http://localhost:3000/`。 - -# 用户模型 - -我们将在`server/models/user.model.js`文件中实现用户模型,使用 Mongoose 用必要的用户数据字段定义模式,为字段添加内置验证,并合并业务逻辑,如密码加密、身份验证和自定义验证。 - -我们将首先导入`mongoose`模块并使用它生成`UserSchema`。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -import mongoose from 'mongoose' - -const UserSchema = new mongoose.Schema({ … }) -``` - -`mongoose.Schema()`函数将模式定义对象作为参数,以生成新的 Mongoose 模式对象,该对象可用于后端代码的其余部分。 - -# 用户模式定义 - -生成新 Mongoose 模式所需的用户模式定义对象将声明所有用户数据字段和相关属性。 - -# 名称 - -`name`字段是`String`类型的必填字段。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -name: { - type: String, - trim: true, - required: 'Name is required' - }, -``` - -# 电子邮件 - -`email`字段是`String`类型的必填字段,必须匹配有效的电子邮件格式,并且必须在用户集合中为`unique`。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -email: { - type: String, - trim: true, - unique: 'Email already exists', - match: [/.+\@.+\..+/, 'Please fill a valid email address'], - required: 'Email is required' -}, -``` - -# 创建和更新时间戳 - -字段`created`和`updated`是通过编程生成的`Date`值,用于记录正在创建和更新的用户的时间戳。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -created: { - type: Date, - default: Date.now -}, -updated: Date, -``` - -# 哈希密码和 salt - -`hashed_password`和`salt`字段表示我们将用于身份验证的加密用户密码。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -hashed_password: { - type: String, - required: "Password is required" -}, -salt: String -``` - -出于安全目的,实际密码字符串不会直接存储在数据库中,而是单独处理。 - -# 身份验证密码 - -密码字段对于在任何应用中提供安全的用户身份验证都非常重要,它需要作为用户模型的一部分进行加密、验证和安全身份验证。 - -# 作为虚拟场 - -用户提供的`password`字符串不是直接存储在用户文档中,而是作为`virtual`字段处理。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -UserSchema - .virtual('password') - .set(function(password) { - this._password = password - this.salt = this.makeSalt() - this.hashed_password = this.encryptPassword(password) - }) - .get(function() { - return this._password - }) -``` - -当用户创建或更新时收到`password`值时,它将被加密为新的散列值,并与`salt`字段中的`salt`值一起设置为`hashed_password`字段。 - -# 加密和认证 - -用于生成表示`password`值的`hashed_password`和`salt`值的加密逻辑和 salt 生成逻辑被定义为`UserSchema`方法。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -UserSchema.methods = { - authenticate: function(plainText) { - return this.encryptPassword(plainText) === this.hashed_password - }, - encryptPassword: function(password) { - if (!password) return '' - try { - return crypto - .createHmac('sha1', this.salt) - .update(password) - .digest('hex') - } catch (err) { - return '' - } - }, - makeSalt: function() { - return Math.round((new Date().valueOf() * Math.random())) + '' - } -} -``` - -此外,`authenticate`方法也被定义为`UserSchema`方法,当用户提供的密码必须经过身份验证才能登录时使用。 - -节点中的`crypto`模块用于将用户提供的密码字符串加密成一个`hashed_password`,随机生成`salt`值。当用户详细信息在创建或更新时保存到数据库时,`hashed_password`和 salt 存储在用户文档中。为了使用前面定义的`authenticate`方法匹配和验证用户登录期间提供的密码字符串,需要使用`hashed_password`和`salt`值。 - -# 密码字段验证 - -要对最终用户选择的实际密码字符串添加验证约束,我们需要添加自定义验证逻辑,并将其与模式中的`hashed_password`字段相关联。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -UserSchema.path('hashed_password').validate(function(v) { - if (this._password && this._password.length < 6) { - this.invalidate('password', 'Password must be at least 6 characters.') - } - if (this.isNew && !this._password) { - this.invalidate('password', 'Password is required') - } -}, null) -``` - -为了确保确实提供了密码值,并且在创建新用户或更新现有密码时,密码值的长度至少为六个字符,在 Mongoose 尝试存储`hashed_password`值之前,会添加自定义验证以检查密码值。如果验证失败,逻辑将返回相关错误消息。 - -一旦`UserSchema`被定义,并且所有与密码相关的业务逻辑都如前面所讨论的那样被添加,我们最终可以导出`user.model.js`文件底部的模式,以便在后端代码的其他部分使用它。 - -`mern-skeleton/server/models/user.model.js`: - -```jsx -export default mongoose.model('User', UserSchema) -``` - -# Mongoose 错误处理 - -如果在将用户数据保存到数据库时违反,添加到用户架构字段的验证约束将抛出错误消息。为了处理这些验证错误和数据库在查询时可能抛出的其他错误,我们将定义一个 helper 方法来返回相关的错误消息,该错误消息可以在请求-响应周期中传播。  - -我们将在`server/helpers/dbErrorHandler.js`文件中添加`getErrorMessage`助手方法。此方法将解析并返回与特定验证错误或使用 Mongoose 查询 MongoDB 时发生的其他错误相关联的错误消息。 - -`mern-skeleton/server/helpers/dbErrorHandler.js`: - -```jsx -const getErrorMessage = (err) => { - let message = '' - if (err.code) { - switch (err.code) { - case 11000: - case 11001: - message = getUniqueErrorMessage(err) - break - default: - message = 'Something went wrong' - } - } else { - for (let errName in err.errors) { - if (err.errors[errName].message) - message = err.errors[errName].message - } - } - return message -} - -export default {getErrorMessage} -``` - -由于 Mongoose 验证程序冲突而未引发的错误将包含错误代码,在某些情况下需要以不同方式处理。例如,由于违反唯一约束而导致的错误将返回与 Mongoose 验证错误不同的错误对象。unique 选项不是验证器,而是构建 MongoDB unique 索引的方便助手,因此我们将添加另一个`getUniqueErrorMessage`方法来解析与 unique constraint 相关的错误对象,并构造适当的错误消息。 - -`mern-skeleton/server/helpers/dbErrorHandler.js`: - -```jsx -const getUniqueErrorMessage = (err) => { - let output - try { - let fieldName = - err.message.substring(err.message.lastIndexOf('.$') + 2, - err.message.lastIndexOf('_1')) - output = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) + - ' already exists' - } catch (ex) { - output = 'Unique field already exists' - } - return output -} -``` - -通过使用从此帮助文件导出的`getErrorMessage`函数,我们将在处理针对用户 CRUD 执行的 Mongoose 操作引发的错误时添加有意义的错误消息。 - -# 用户 CRUDAPI - -Express 应用公开的用户 API 端点将允许前端对根据用户模型生成的文档执行 CRUD 操作。为了实现这些工作端点,我们将编写 Express routes 和相应的控制器回调函数,这些函数应该在 HTTP 请求进入这些已声明的路由时执行。在本节中,我们将了解这些端点如何在没有任何身份验证限制的情况下工作。 - -用户 API 路由将使用`server/routes/user.routes.js`中的 Express router 进行声明,然后挂载在`server/express.js`中配置的 Express app 上。 - -`mern-skeleton/server/express.js`: - -```jsx -import userRoutes from './routes/user.routes' -... -app.use('/', userRoutes) -... -``` - -# 用户路由 - -`user.routes.js`文件中定义的用户路由将使用`express.Router()`以相关 HTTP 方法声明路由路径,并分配服务器收到这些请求时应调用的相应控制器函数。 - -我们将通过使用以下方法使用户路线保持简单: - -* `/api/users`针对: - * 使用 GET 列出用户 - * 使用 POST 创建新用户 -* `/api/users/:userId`针对: - * 使用 GET 获取用户 - * 使用 PUT 更新用户 - * 使用 DELETE 删除用户 - -生成的`user.routes.js`代码如下所示(不考虑需要为受保护路由添加的身份验证)。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -import express from 'express' -import userCtrl from '../controllers/user.controller' - -const router = express.Router() - -router.route('/api/users') - .get(userCtrl.list) - .post(userCtrl.create) - -router.route('/api/users/:userId') - .get(userCtrl.read) - .put(userCtrl.update) - .delete(userCtrl.remove) - -router.param('userId', userCtrl.userByID) - -export default router -``` - -# 用户控制器 - -当服务器收到路由请求时,`server/controllers/user.controller.js`文件将包含前面的用户路由声明中作为回调使用的控制器方法。 - -`user.controller.js`文件将具有以下结构: - -```jsx -import User from '../models/user.model' -import _ from 'lodash' -import errorHandler from './error.controller' - -const create = (req, res, next) => { … } -const list = (req, res) => { … } -const userByID = (req, res, next, id) => { … } -const read = (req, res) => { … } -const update = (req, res, next) => { … } -const remove = (req, res, next) => { … } - -export default { create, userByID, read, list, remove, update } -``` - -当出现猫鼬错误时,控制器将使用`errorHandler`助手以有意义的消息响应路由请求。当使用更改的值更新现有用户时,它还将使用名为`lodash`的模块。 - -`lodash` is a JavaScript library which provides utility functions for common programming tasks including manipulation of arrays and objects. To install `lodash`, run `npm install lodash --save` from command line.  - -前面定义的每个控制器函数都与路由请求相关,并将根据每个 API 用例进行详细说明。 - -# 创建新用户 - -创建新用户的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -router.route('/api/users').post(userCtrl.create) -``` - -当 Express app 在`'/api/users'`收到 POST 请求时,调用控制器中定义的`create`函数。 - -`mern-skeleton/server/controllers/user.controller.js`: - -```jsx -const create = (req, res, next) => { - const user = new User(req.body) - user.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.status(200).json({ - message: "Successfully signed up!" - }) - }) -} -``` - -此函数使用`req.body`内来自前端的 POST 请求中接收到的 user JSON 对象创建一个新用户。在 Mongoose 对数据进行验证检查后,`user.save`尝试将新用户保存到数据库中,因此错误或成功响应返回给请求的客户端。 - -# 列出所有用户 - -获取所有用户的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -router.route('/api/users').get(userCtrl.list) -``` - -当 Express app 在`'/api/users'`收到 GET 请求时,执行`list`控制器功能。 - -`mern-skeleton/server/controllers/user.controller.js`: - -```jsx -const list = (req, res) => { - User.find((err, users) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(users) - }).select('name email updated created') -} -``` - -`list`控制器函数从数据库中查找所有用户,仅填充结果用户列表中的名称、电子邮件、创建和更新字段,然后将此用户列表作为数组中的 JSON 对象返回给请求客户端。 - -# 按 ID 加载用户以进行读取、更新或删除 - -用于读取、更新和删除的所有三个 API 端点都要求根据所访问用户的用户 ID 从数据库中检索用户。在响应读取、更新或删除的特定请求之前,我们将对 Express router 进行编程,使其首先执行此操作。 - -# 加载 - -当 Express 应用接收到与包含`:userId`参数的路径匹配的路由请求时,该应用将首先执行`userByID`控制器功能,然后再传播到特定于传入请求的`next`功能。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -router.param('userId', userCtrl.userByID) -``` - -`userByID`控制器功能使用`:userId`参数中的值通过`_id`查询数据库,并加载匹配的用户详细信息。 - -`mern-skeleton/server/controllers/user.controller.js`: - -```jsx -const userByID = (req, res, next, id) => { - User.findById(id).exec((err, user) => { - if (err || !user) - return res.status('400').json({ - error: "User not found" - }) - req.profile = user - next() - }) -} -``` - -如果在数据库中找到匹配的用户,则用户对象将附加到`profile`键中的请求对象。然后,使用`next()`中间件将控制传播到下一个相关控制器功能。例如,如果原始请求是读取用户配置文件,`userById`中的`next()`调用将转到`read`控制器功能。 - -# 阅读 - -用于读取单个用户数据的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -router.route('/api/users/:userId').get(userCtrl.read) -``` - -当 Express app 在`'/api/users/:userId'`收到 GET 请求时,执行`userByID`控制器功能,通过参数中的`userId`值加载用户,然后执行`read`控制器功能。 - -`mern-skeleton/server/controllers/user.controller.js`: - -```jsx -const read = (req, res) => { - req.profile.hashed_password = undefined - req.profile.salt = undefined - return res.json(req.profile) -} -``` - -`read`函数从`req.profile`中检索用户详细信息,并删除敏感信息,如`hashed_password`和`salt`值,然后将响应中的用户对象发送给请求客户端。 - -# 更新 - -要更新单个用户的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -router.route('/api/users/:userId').put(userCtrl.update) -``` - -当 Express app 在`'/api/users/:userId'`收到 PUT 请求时,与`read`类似,它首先向用户加载`:userId`参数值,然后执行`update`控制器功能。 - -`mern-skeleton/server/controllers/user.controller.js`: - -```jsx -const update = (req, res, next) => { - let user = req.profile - user = _.extend(user, req.body) - user.updated = Date.now() - user.save((err) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - user.hashed_password = undefined - user.salt = undefined - res.json(user) - }) -} -``` - -`update`函数从`req.profile`中检索用户详细信息,然后使用`lodash`模块扩展和合并请求正文中的更改,以更新用户数据。在将此更新的用户保存到数据库之前,`updated`字段将填充当前日期,以反映上次更新的时间戳。成功保存此更新后,在将响应中的用户对象发送给请求客户端之前,通过删除敏感数据(如`hashed_password`和`salt`)来清理更新后的用户对象。 - -# 删除 - -要删除用户的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -router.route('/api/users/:userId').delete(userCtrl.remove) -``` - -当 Express app 在`'/api/users/:userId'`收到删除请求时,与读取和更新类似,它首先按 ID 加载用户,然后执行`remove`控制器功能。 - -`mern-skeleton/server/controllers/user.controller.js`: - -```jsx -const remove = (req, res, next) => { - let user = req.profile - user.remove((err, deletedUser) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - deletedUser.hashed_password = undefined - deletedUser.salt = undefined - res.json(deletedUser) - }) -} -``` - -`remove`函数从`req.profile`中检索用户,并使用`remove()`查询从数据库中删除该用户。成功删除后,请求客户端将在响应中返回已删除的用户对象。 - -到目前为止,随着 API 端点的实现,任何客户端都可以对用户模型执行 CRUD 操作,但我们希望通过身份验证和授权限制对其中一些操作的访问。 - -# 用户身份验证和受保护的路由 - -为了限制对用户操作的访问,例如用户概要视图、用户更新和用户删除,我们将使用 JWT 实现登录身份验证,然后保护和授权读取、更新和删除路由。 - -用于登录和注销的与身份验证相关的 API 端点将在`server/routes/auth.routes.js`中声明,然后安装在`server/express.js`中的 Express app 上。 - -`mern-skeleton/server/express.js`: - -```jsx -import authRoutes from './routes/auth.routes' - ... - app.use('/', authRoutes) - ... -``` - -# 认证路由 - -在`auth.routes.js`文件中使用`express.Router()`定义了这两个身份验证 API,以使用相关 HTTP 方法声明路由路径,并分配了相应的身份验证控制器函数,当收到这些路由的请求时应调用这些函数。 - -验证路径如下所示: - -* `'/auth/signin'`:用电子邮件和密码对用户进行身份验证的 POST 请求 -* `'/auth/signout'`:获取请求以清除在登录后在响应对象上设置的包含 JWT 的 cookie - -生成的`mern-skeleton/server/routes/auth.routes.js`文件如下: - -```jsx -import express from 'express' -import authCtrl from '../controllers/auth.controller' - -const router = express.Router() - -router.route('/auth/signin') - .post(authCtrl.signin) -router.route('/auth/signout') - .get(authCtrl.signout) - -export default router -``` - -# 身份验证控制器 - -`server/controllers/auth.controller.js`中的 auth controller 函数不仅将处理对登录和注销路由的请求,还将提供 JWT 和`express-jwt`功能,以便为受保护的用户 API 端点启用身份验证和授权。 - -`auth.controller.js`文件将具有以下结构: - -```jsx -import User from '../models/user.model' -import jwt from 'jsonwebtoken' -import expressJwt from 'express-jwt' -import config from './../../config/config' - -const signin = (req, res) => { … } -const signout = (req, res) => { … } -const requireSignin = … -const hasAuthorization = (req, res) => { … } - -export default { signin, signout, requireSignin, hasAuthorization } -``` - -下面将详细介绍四个控制器函数,以展示后端如何使用 JSON Web 令牌实现用户身份验证。 - -# 登录 - -要登录用户的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/auth.routes.js`: - -```jsx -router.route('/auth/signin').post(authCtrl.signin) -``` - -当 Express app 在`'/auth/signin'`收到 POST 请求时,执行`signin`控制器功能。 - -`mern-skeleton/server/controllers/auth.controller.js`: - -```jsx -const signin = (req, res) => { - User.findOne({ - "email": req.body.email - }, (err, user) => { - if (err || !user) - return res.status('401').json({ - error: "User not found" - }) - - if (!user.authenticate(req.body.password)) { - return res.status('401').send({ - error: "Email and password don't match." - }) - } - - const token = jwt.sign({ - _id: user._id - }, config.jwtSecret) - - res.cookie("t", token, { - expire: new Date() + 9999 - }) - - return res.json({ - token, - user: {_id: user._id, name: user.name, email: user.email} - }) - }) -} -``` - -`POST`请求对象接收`req.body`中的电子邮件和密码。此电子邮件用于从数据库检索匹配的用户。然后,使用`UserSchema`中定义的密码认证方法来验证`req.body`中从客户端接收到的密码。 - -如果密码验证成功,则使用 JWT 模块生成使用密钥和用户的`_id`值签名的 JWT。 - -Install the `jsonwebtoken` module to make it available to this controller in the import by running `npm install jsonwebtoken --save` from the command line. - -然后,签名的 JWT 连同用户详细信息一起返回给经过身份验证的客户端。或者,我们还可以将令牌设置为响应对象中的 cookie,以便在 cookie 是所选 JWT 存储形式的情况下,客户端可以使用该令牌。在客户端,当从服务器请求受保护的路由时,此令牌必须作为`Authorization`头附加。 - -# 注销 - -用于注销用户的 API 端点在以下路由中声明。 - -`mern-skeleton/server/routes/auth.routes.js`: - -```jsx -router.route('/auth/signout').get(authCtrl.signout) -``` - -当 Express app 在`'/auth/signout'`收到 GET 请求时,执行`signout`控制器功能。 - -`mern-skeleton/server/controllers/auth.controller.js`: - -```jsx -const signout = (req, res) => { - res.clearCookie("t") - return res.status('200').json({ - message: "signed out" - }) -} -``` - -`signout`函数清除包含签名 JWT 的响应 cookie。这是一个可选的端点,如果前端中根本没有使用 cookies,那么就不需要进行身份验证。使用 JWT,用户状态存储是客户端的责任,除了 cookie 之外,客户端存储还有多种选择。注销时,客户端需要删除客户端上的令牌,以确定用户不再经过身份验证。 - -# 使用快速 jwt 保护路由 - -为了保护对读取、更新和删除路由的访问,服务器需要检查请求的客户机是否确实是经过身份验证和授权的用户。 - -为了在访问受保护的路由时检查请求用户是否已登录并具有有效的 JWT,我们将使用`express-jwt`模块。 - -The `express-jwt` module is middleware that validates JSON Web Tokens. Run `npm install express-jwt --save` from the command line to install `express-jwt`. - -# 需要登录 - -`auth.controller.js`中的`requireSignin`方法使用`express-jwt`来验证传入请求在`Authorization`头中是否有有效的 JWT。如果令牌有效,它将在`'auth'`密钥中向请求对象追加已验证用户的 ID,否则抛出身份验证错误。 - -`mern-skeleton/server/controllers/auth.controller.js`: - -```jsx -const requireSignin = expressJwt({ - secret: config.jwtSecret, - userProperty: 'auth' -}) -``` - -我们可以将`requireSignin`添加到任何应该受到保护的路由,以防止未经验证的访问。 - -# 授权已登录用户 - -对于某些受保护的路由(如更新和删除),在检查身份验证的基础上,我们还希望确保请求用户只更新或删除他们自己的用户信息。为了实现这一点,`auth.controller.js`中定义的`hasAuthorization`功能在允许相应 CRUD 控制器功能继续之前,检查经过身份验证的用户是否与被更新或删除的用户相同。 - -`mern-skeleton/server/controllers/auth.controller.js`: - -```jsx -const hasAuthorization = (req, res, next) => { - const authorized = req.profile && req.auth && req.profile._id == - req.auth._id - if (!(authorized)) { - return res.status('403').json({ - error: "User is not authorized" - }) - } - next() -} -``` - -验证后,`req.auth`对象由`requireSignin`中的`express-jwt`填充,`req.profile`对象由`user.controller.js`中的`userByID`函数填充。我们将在需要身份验证和授权的路由中添加`hasAuthorization`功能。 - -# 保护用户路由 - -我们将在需要通过身份验证和授权进行保护的用户路由声明中添加`requireSignin`和`hasAuthorization`。 - -更新`user.routes.js`中的读取、更新和删除路由,如下所示。 - -`mern-skeleton/server/routes/user.routes.js`: - -```jsx -import authCtrl from '../controllers/auth.controller' -... -router.route('/api/users/:userId') - .get(authCtrl.requireSignin, userCtrl.read) - .put(authCtrl.requireSignin, authCtrl.hasAuthorization, - userCtrl.update) - .delete(authCtrl.requireSignin, authCtrl.hasAuthorization, - userCtrl.remove) -... -``` - -读取用户信息的路由只需要身份验证,而更新和删除路由应在执行这些 CRUD 操作之前检查身份验证和授权。 - -# express jwt 的身份验证错误处理 - -为了处理`express-jwt`在尝试验证传入请求中的 JWT 令牌时抛出的与身份验证相关的错误,我们需要在`mern-skeleton/server/express.js`中的 Express app 配置中添加以下错误捕获代码,该代码位于代码末尾附近,在装载路由之后和导出应用之前: - -```jsx -app.use((err, req, res, next) => { - if (err.name === 'UnauthorizedError') { - res.status(401).json({"error" : err.name + ": " + err.message}) - } -}) -``` - -当由于某种原因无法验证令牌时,`express-jwt`抛出名为`UnauthorizedError`的错误。我们在这里捕获此错误,以将`401`状态返回给请求客户端。 - -通过实现用于保护路由的用户身份验证,我们已经介绍了骨架 MERN 应用工作后端的所有所需功能。在下一节中,我们将了解如何在不实现前端的情况下检查此独立后端是否按预期运行。 - -# 检查独立后端 - -在选择检查后端 API 的工具时,有许多选项,从命令行工具 curl([到 https://github.com/curl/curl](https://github.com/curl/curl) 至高级 REST 客户端([https://chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo](https://chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo) -具有交互式用户界面的 Chrome 扩展应用。 - -要检查本章中实现的 API,首先让服务器从命令行运行,并使用以下任一工具请求路由。如果您正在本地计算机上运行代码,则根 URL 为`http://localhost:3000/`。 - -使用 ARC,我们将展示检查实现的 API 端点的五个用例的预期行为。 - -# 创建新用户 - -首先,我们将使用`/api/users`POST 请求创建一个新用户,并在请求正文中传递名称、电子邮件和密码值。当用户在数据库中成功创建且没有任何验证错误时,我们将看到一条 200 OK 成功消息,如以下屏幕截图所示: - -![](img/a33bc049-08a1-4fc9-b5ae-33f3f08a4ce1.png) - -# 获取用户列表 - -我们可以通过获取所有用户的列表,并向`/api/users`发出`GET`请求来查看新用户是否在数据库中。响应应包含存储在数据库中的所有用户对象的数组: - -![](img/a9f44f9e-5f75-4c8e-875b-0c9eeedf7308.png) - -# 正在尝试获取单个用户 - -接下来,我们将尝试在不首先登录的情况下访问受保护的 API。`GET`读取任何一个用户的请求将返回 401 未经授权,例如在下面的示例中,`GET`对`/api/users/5a1c7ead1a692aa19c3e7b33`的请求将返回 401: - -![](img/6d089bdf-7f14-480c-97b3-4217eeac894b.png) - -# 登录 - -为了能够访问受保护的路由,我们将使用在第一个示例中创建的用户的凭据登录。要登录,将在`/auth/signin`发送 POST 请求,请求正文中包含电子邮件和密码。成功登录后,服务器返回已签名的 JWT 和用户详细信息。我们需要此令牌来访问受保护的路由以获取单个用户: - -![](img/6f5444db-3e39-4a3f-8f8e-fd0ef0976303.png) - -# 正在成功获取单个用户 - -使用登录后收到的令牌,我们现在可以访问以前失败的受保护路由。向`/api/users/5a1c7ead1a692aa19c3e7b33`发出 GET 请求时,在承载方案的`Authorization`头中设置了令牌,此次成功返回用户对象: - -![](img/93af55fd-9288-4870-9250-549a0df79e08.png) - -# 总结 - -在本章中,我们使用 Node、Express 和 MongoDB 开发了一个功能齐全的独立服务器端应用,涵盖了 MERN 骨架应用的第一部分。在后端,我们实现了以下功能: - -* 存储用户数据的用户模型,用 Mongoose 实现 -* 用于执行 CRUD 操作的用户 API 端点,使用 Express 实现 -* 受保护路由的用户身份验证,使用 JWT 和`express-jwt`实现 - -我们还通过配置 Webpack 来编译 ES6 代码,并配置 Nodemon 在代码更改时重新启动服务器来设置开发流程。最后,我们使用适用于 Chrome 的高级 RESTAPI 客户端扩展应用检查了 API 的实现 - -我们现在准备在下一章中扩展此后端应用代码,以添加 React 前端并完成 MERN 骨架应用。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/04.md b/docs/full-stk-react-proj/04.md deleted file mode 100644 index e51ecc5..0000000 --- a/docs/full-stk-react-proj/04.md +++ /dev/null @@ -1,1609 +0,0 @@ -# 四、添加 React 前端来完成 MERN - -没有前端,web 应用是不完整的。它是用户与之交互的部分,对任何 web 体验都至关重要。在本章中,我们将使用 React 将交互式用户界面添加到在上一章中开始构建的 MERN skeleton 应用后端实现的基本用户和身份验证功能中。 - -我们将介绍以下主题,以添加工作前端并完成 MERN skeleton 应用: - -* 骨架的前端特征 -* 使用 React、React 路由和物料 UI 设置开发 -* 后端用户 API 集成 -* 身份验证集成 -* 主页、用户、注册、登录、用户配置文件、编辑和删除视图 -* 导航菜单 -* 基本服务器端渲染 - -# 骨架前端 - -为了全面实现[第 3 章](03.html)的*功能分解*部分中讨论的框架应用功能,*使用 MongoDB、Express 和 Node*构建后端,我们将向基础应用添加以下用户界面组件: - -* **主页**:在根 URL 处呈现的视图,用于欢迎用户访问 web 应用 -* **用户列表页面**:获取并显示数据库中所有用户列表的视图,还可以链接到各个用户配置文件 -* **注册页面**:带有用户注册表单的视图,允许新用户创建用户帐户,并在成功创建后将其重定向到登录页面 -* **登录页面**:一个带有登录表单的视图,允许现有用户登录,以便访问受保护的视图和操作 -* **个人资料页面**:一个获取和显示个人用户信息的组件,只有登录用户才能访问,还包含编辑和删除选项,只有登录用户查看自己的个人资料时才可见 -* **编辑个人资料页面**:一种表单,用于获取表单中的用户信息,允许用户编辑信息,并且只有登录用户试图编辑自己的个人资料时才可访问 -* **删除用户组件**:允许登录用户在确认其意图后只删除自己的个人资料的选项 -* **菜单导航栏**:向用户列出所有可用和相关视图的组件,也有助于指示用户在应用中的当前位置 - -以下 React 组件树图显示了我们将开发的所有 React 组件,以构建此基础应用的视图: - -![](img/a7c89a42-7cb4-41d2-a7b8-db331fbb9301.png) - -**MainRouter**将是根 React 组件,其中包含应用中所有其他自定义 React 视图。**Home**、**Signup**、**Signin**、**Users**、**Profile**和**EditProfile**将在使用 React Router 声明的各个路由上呈现,**菜单**组件将呈现所有这些视图,**DeleteUser**将是**剖面**视图的一部分。 - -The code discussed in this chapter, and for the complete skeleton, is available on GitHub in the repository at [github.com/shamahoque/mern-skeleton](https://github.com/shamahoque/mern-skeleton). You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -# 文件夹和文件结构 - -以下文件夹结构显示了要添加到骨架中的新文件夹和文件,以使用 React 前端完成骨架: - -```jsx -| mern_skeleton/ - | -- client/ - | --- img/ - | ---- images/ - | --- auth/ - | ---- api-auth.js - | ---- auth-helper.js - | ---- PrivateRoute.js - | ---- Signin.js - | --- core/ - | ---- Home.js - | ---- Menu.js - | --- user/ - | ---- api-user.js - | ---- DeleteUser.js - | ---- EditProfile.js - | ---- Profile.js - | ---- Signup.js - | ---- Users.js - | --- App.js - | --- main.js - | --- MainRouter.js - | -- server/ - | --- devBundle.js - | -- webpack.config.client.js - | -- webpack.config.client.production.js -``` - -客户端文件夹将包含 React 组件、帮助程序和前端资产,如图像和 CSS。除了此文件夹和用于编译和绑定客户端代码的 Webpack 配置之外,我们还将修改其他一些现有文件以集成完整的框架。 - -# 为 React 开发设置 - -在我们可以在现有的框架代码库中开始使用 React 进行开发之前,我们首先需要添加配置来编译和绑定前端代码,添加构建交互界面所需的 React 相关依赖项,并将其连接到 MERN 开发流程中。 - -# 配置 Babel 和 Webpack - -为了在开发过程中编译和绑定客户端代码以运行它,并将其绑定到生产环境中,我们将更新 Babel 和 Webpack 的配置。 - -# 巴别塔 - -要编译 React,首先将 Babel React 预置模块作为开发依赖项安装: - -```jsx -npm install babel-preset-react --save-dev -``` - -然后,更新`.babelrc`以包含该模块,并根据`react-hot-loader`模块的需要配置`react-hot-loader`巴别塔插件。 - -`mern-skeleton/.babelrc`: - -```jsx -{ - "presets": [ - "env", - "stage-2", - "react" - ], - "plugins": [ - "react-hot-loader/babel" - ] -} -``` - -# 网页包 - -要在使用 Babel 编译后捆绑客户端代码,并启用`react-hot-loader`以加快开发,请安装以下模块: - -```jsx -npm install --save-dev webpack-dev-middleware webpack-hot-middleware file-loader -npm install --save react-hot-loader -``` - -然后,为了为前端开发配置 Webpack 并构建生产包,我们将添加一个`webpack.config.client.js`文件和一个`webpack.config.client.production.js`文件,其配置代码与[第 2 章](02.html)、*准备开发环境*中描述的相同。 - -# 加载 Web 包中间件进行开发 - -在开发过程中,当我们运行服务器时,Express app 应根据客户端代码的配置集加载与前端相关的 Webpack 中间件,以便集成前端和后端开发工作流。为了实现这一点,我们将使用[第 2 章](02.html)中讨论的`devBundle.js`文件*准备开发环境*来建立一个`compile`方法,将 Express app 配置为使用 Webpack 中间件。`server`文件夹中的`devBundle.js`如下所示。 - -`mern-skeleton/server/devBundle.js`: - -```jsx -import config from './../config/config' -import webpack from 'webpack' -import webpackMiddleware from 'webpack-dev-middleware' -import webpackHotMiddleware from 'webpack-hot-middleware' -import webpackConfig from './../webpack.config.client.js' - -const compile = (app) => { - if(config.env === "development"){ - const compiler = webpack(webpackConfig) - const middleware = webpackMiddleware(compiler, { - publicPath: webpackConfig.output.publicPath - }) - app.use(middleware) - app.use(webpackHotMiddleware(compiler)) - } -} - -export default { - compile -} -``` - -然后,在`express.js`中导入并调用此`compile`方法,只在开发时添加以下突出显示的行。 - -`mern-skeleton/server/express.js`: - -```jsx -import devBundle from './devBundle' -const app = express() -devBundle.compile(app) - -``` - -这两行突出显示的代码仅用于开发模式,在构建用于生产的代码时应注释掉。当 Express app 在开发模式下运行时,此代码将在启动 Webpack 编译和捆绑客户端代码之前导入中间件和 Webpack 配置。捆绑的代码将放在`dist`文件夹中 - -# 使用 Express 服务静态文件 - -为了确保 Express server 正确处理对静态文件(如 CSS 文件、图像或捆绑客户端 JS)的请求,我们将通过在`express.js`中添加以下配置,将其配置为服务`dist`文件夹中的静态文件。 - -`mern-skeleton/server/express.js`: - -```jsx -import path from 'path' -const CURRENT_WORKING_DIR = process.cwd() -app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist'))) -``` - -# 更新模板以加载捆绑脚本 - -为了在 HTML 视图中添加捆绑的前端代码,我们将更新`template.js`文件,将脚本文件从`dist`文件夹添加到``标记的末尾。 - -`mern-skeleton/template.js`: - -```jsx -... - -
- - -``` - -# 添加 React 依赖项 - -前端视图将主要使用 React 实现。此外,为了实现客户端路由,我们将使用 React Router,为了增强用户体验,我们将使用 Material UI。 - -# 反应 - -在本书中,我们将使用 React 16 对前端进行编码。要开始编写`React`组件代码,我们需要安装以下模块作为常规依赖项: - -```jsx -npm install --save react react-dom -``` - -# 反应路由 - -React Router 提供一组导航组件,用于在前端为 React 应用进行路由。为了利用声明式路由和可书签的 URL 路由,我们将添加以下 React 路由模块: - -```jsx -npm install --save react-router react-router-dom -``` - -# 材料界面 - -为了使我们的 MERN 应用中的 UI 保持光滑,而不必过多地钻研 UI 设计和实现,我们将利用`Material-UI`库。它提供现成的、可定制的`React`组件,实现谷歌的材料设计。要开始使用 Material UI 组件制作前端,我们需要安装以下模块: - -```jsx -npm install --save material-ui@1.0.0-beta.43 material-ui-icons -``` - -At the time of writing, the latest pre-release version of Material-UI is `1.0.0-beta.43` and it is recommended to install this exact version in order to ensure the code for the example projects do not break. - -要按照 Material UI 的建议添加`Roboto`字体,并使用`Material-UI`图标,我们将在 HTML 文档的``部分的`template.js`文件中添加相关的样式链接: - -```jsx - - -``` - -开发配置都设置好了,必要的 React 模块添加到代码库中,我们现在可以开始实现定制的 React 组件了。 - -# 实现 React 视图 - -功能性前端应将 React 组件与后端 API 集成,并允许用户基于授权在应用内无缝导航。为了演示如何实现此 MERN 框架的功能性前端视图,我们将首先详细介绍如何在根路径上呈现主页组件,然后介绍后端 API 和用户身份验证集成,然后重点介绍实现其余视图组件的独特方面。 - -# 呈现主页 - -在根路由上实现和呈现一个工作的`Home`组件的过程也将公开框架中前端代码的基本结构。我们将从顶层入口组件开始,该组件包含整个 React 应用,并呈现连接应用中所有 React 组件的主路由组件。 - -# main.js 的入口点 - -客户端文件夹中的`client/main.js`文件将是呈现完整 React 应用的入口点。在这段代码中,我们导入将包含完整前端的根或顶级 React 组件,并将其呈现给具有在`template.js`中的 HTML 文档中指定的 ID`'root'`的`div`元素。 - -`mern-skeleton/client/main.js`: - -```jsx -import React from 'react' -import { render } from 'react-dom' -import App from './App' - -render(, document.getElementById('root')) -``` - -# 根反应组分 - -包含应用前端所有组件的顶级 React 组件在`client/App.js`文件中定义。在该文件中,我们配置 React 应用,以使用自定义材质 UI 主题呈现视图组件,启用前端路由,并确保 React Hot Loader 可以在开发组件时立即加载更改。 - -# 自定义材质 UI 主题 - -可以使用`MuiThemeProvider`组件,通过在`createMuiTheme()`中为主题变量配置自定义值,轻松定制物料 UI 主题。 - -`mern-skeleton/client/App.js`: - -```jsx -import {MuiThemeProvider, createMuiTheme} from 'material-ui/styles' -import {indigo, pink} from 'material-ui/colors' - -const theme = createMuiTheme({ - palette: { - primary: { - light: '#757de8', - main: '#3f51b5', - dark: '#002984', - contrastText: '#fff', - }, - secondary: { - light: '#ff79b0', - main: '#ff4081', - dark: '#c60055', - contrastText: '#000', - }, - openTitle: indigo['400'], - protectedTitle: pink['400'], - type: 'light' - } -}) - -``` - -对于骨架,我们只通过设置一些要在 UI 中使用的颜色值来应用最小的定制。这里生成的主题变量将传递给我们构建的所有组件,并在其中可用。 - -# 使用 MUI 主题和 BrowserRouter 包装根组件 - -我们为组成用户界面而创建的自定义 React 组件将通过`MainRouter`组件中指定的前端路由进行访问。基本上,该组件包含为应用开发的所有自定义视图。在`App.js`中定义根组件时,我们用`MuiThemeProvider`包装`MainRouter`组件,使其能够访问物料 UI 主题,`BrowserRouter`使用 React Router 启用前端路由。前面定义的自定义主题变量作为道具传递给`MuiThemeProvider`,使主题在所有自定义组件中可用。 - -`mern-skeleton/client/App.js`: - -```jsx -import React from 'react' -import MainRouter from './MainRouter' -import {BrowserRouter} from 'react-router-dom' - -const App = () => ( - - - - - -) -``` - -# 将根组件标记为热导出 - -`App.js`中导出`App`组件的最后一行代码使用`react-hot-loader`中的`hot`模块将根组件标记为`hot`。这将允许在开发过程中实时重新加载 React 组件。 - -`mern-skeleton/client/App.js`: - -```jsx -import { hot } from 'react-hot-loader' -... -export default hot(module)(App) -``` - -对于我们的 MERN 应用,在这一点之后,我们不必对`main.js`和`App.js`代码进行太多更改,我们可以通过在`MainRouter`组件中注入新组件继续构建 React 应用的其余部分。 - -# 向主路由添加主路由 - -`MainRouter.js`代码将有助于根据应用中的路由或位置呈现我们的自定义 React 组件。在第一个版本中,我们将只添加根路由来呈现`Home`组件。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx -import React, {Component} from 'react' -import {Route, Switch} from 'react-router-dom' -import Home from './core/Home' -class MainRouter extends Component { - render() { - return (
- - - -
) - } -} -export default MainRouter -``` - -随着我们开发更多的视图组件,我们将更新`MainRouter`,为`Switch`组件中的新组件添加路由。 - -The `Switch` component in React Router renders a route exclusively. In other words, it only renders the first child that matches the requested route path. Whereas, without being nested in a `Switch`, every `Route` component renders inclusively when there is a path match. For example, a request at `'/'` also matches a route at `'/contact'`. - -# 主分量 - -当用户访问根路径时,`Home`组件将在浏览器上呈现,我们将使用材质 UI 组件组合它。以下屏幕截图显示了`Home`组件和`Menu`组件,这两个组件将在本章后面作为单个组件实现,以提供应用的导航: - -![](img/c3f41131-6f5c-4a1f-8de4-5451b50a243f.png) - -将在浏览器中呈现供用户交互的`Home`组件和其他视图组件将遵循一个通用代码结构,该代码结构按给定顺序包含以下部分。 - -# 进口 - -组件文件将根据特定组件的要求从代码中导入 React、materialui、React 路由模块、图像、CSS、API fetch 和 auth helpers。例如,对于`Home.js`中的`Home`组件代码,我们使用以下导入。 - -`mern-skeleton/client/core/Home.js`: - -```jsx -import React, {Component} from 'react' -import PropTypes from 'prop-types' -import {withStyles} from 'material-ui/styles' -import Card, {CardContent, CardMedia} from 'material-ui/Card' -import Typography from 'material-ui/Typography' -import seashellImg from './../iimg/seashell.jpg' -``` - -图像文件保存在`client/iimg/`文件夹中,并导入/添加到`Home`组件中。 - -# 样式声明 - -导入之后,我们将根据需要使用`Material-UI`主题变量定义 CSS 样式,以设置组件中元素的样式。对于`Home.js`中的`Home`组件,我们有以下样式。 - -`mern-skeleton/client/core/Home.js`: - -```jsx -const styles = theme => ({ - card: { - maxWidth: 600, - margin: 'auto', - marginTop: theme.spacing.unit * 5 - }, - title: { - padding:`${theme.spacing.unit * 3}px ${theme.spacing.unit * 2.5}px - ${theme.spacing.unit * 2}px`, - color: theme.palette.text.secondary - }, - media: { - minHeight: 330 - } -}) -``` - -此处定义的 JSS 样式对象将被注入到组件中,并用于为组件中的元素设置样式,如下面的`Home`组件定义所示。 - -Material-UI uses JSS, which is a CSS-in-JS styling solution to add styles to the components. JSS uses JavaScript as a language to describe styles. This book will not cover CSS and styling implementations in detail. It will most rely on the default look and feel of Material-UI components. To learn more about JSS, visit [http://cssinjs.org/?v=v9.8.1](http://cssinjs.org/?v=v9.8.1). For examples of how to customize the `Material-UI` component styles, check out the Material-UI documentation at [https://material-ui-next.com/](https://material-ui-next.com/). - -# 组件定义 - -在组件定义中,我们将组合组件的内容和行为。`Home`组件将包含一个带有标题、图像和标题的材料 UI`Card`,所有这些都使用前面定义的类进行样式化,并作为道具传入。 - -`mern-skeleton/client/core/Home.js`: - -```jsx -class Home extends Component { - render() { - const {classes} = this.props - return ( -
- - - Home Page - - - - - Welcome to the Mern Skeleton home page - - - -
- ) - } -} -``` - -# 属性类型验证 - -为了验证作为组件支柱的样式声明所需的注入,我们将`PropTypes`需求验证器添加到定义的组件中。 - -`mern-skeleton/client/core/Home.js`: - -```jsx -Home.propTypes = { - classes: PropTypes.object.isRequired -} -``` - -# 导出组件 - -最后,在组件文件的最后一行代码中,我们将导出使用`Material-UI`中的`withStyles`传入的定义样式的组件。这样使用`withStyles`创建一个**高阶组件**(**HOC**,该组件可以访问定义的样式对象作为道具。 - -`mern-skeleton/client/core/Home.js`: - -```jsx -export default withStyles(styles)(Home) -``` - -导出的组件现在可以用于其他组件内的合成,就像我们在前面讨论的`MainRouter`组件中的路径中使用这个`Home`组件一样。 - -要在我们的 MERN 应用中实现的其他视图组件将遵循相同的结构。在本书的其余部分中,我们将主要关注组件定义,重点介绍所实现组件的独特方面。 - -# 捆绑图像资产 - -我们导入到`Home`组件视图中的静态图像文件也必须与其他编译的 JS 代码一起包含在包中,以便代码可以访问和加载它。为了实现这一点,我们需要更新 Webpack 配置文件,以添加一个模块规则,将图像文件加载、捆绑并发送到输出目录,该目录包含已编译的前端和后端代码。 - -使用`babel-loader`后更新`webpack.config.client.js`、`webpack.config.server.js`和`webpack.config.client.production.js`文件,增加以下模块规则: - -```jsx -[ … - { - test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/, - use: 'file-loader' - } -] -``` - -此模块规则使用 Webpack 的`file-loader`npm 模块,该模块需要作为开发依赖项安装,如下所示: - -```jsx -npm install --save-dev file-loader -``` - -# 在浏览器中运行和打开 - -到目前为止,可以运行客户端代码在根 URL 处查看浏览器中的`Home`组件。要运行应用,请使用以下命令: - -```jsx -npm run development -``` - -然后,在浏览器中打开根 URL(`http://localhost:3000`,查看`Home`组件。 - -这里开发的`Home`组件是一个基本的视图组件,没有交互功能,不需要为用户 CRUD 或 auth 使用后端 API。但是,框架前端的其余视图组件将需要后端 API 和身份验证。 - -# 后端 API 集成 - -用户应该能够使用前端视图根据身份验证和授权获取和修改数据库中的用户数据。为了实现这些功能,React 组件将使用 fetchapi 访问后端公开的 API 端点。 - -The Fetch API is a newer standard to make network requests similar to **XMLHttpRequest** (**XHR**) but using promises instead, enabling a simpler and cleaner API. To learn more about the Fetch API, visit [https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). - -# 获取用户 CRUD - -在`client/user/api-user.js`文件中,我们将添加访问每个用户 CRUD API 端点的方法,React 组件可以根据需要使用这些方法与服务器和数据库交换用户数据。 - -# 创建用户 - -`create`方法将从视图组件中获取用户数据,使用`fetch`进行`POST`调用,在后端创建新用户,最后将服务器的响应作为承诺返回给组件。 - -`mern-skeleton/client/user/api-user.js`: - -```jsx -const create = (user) => { - return fetch('/api/users/', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(user) - }) - .then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 列出用户 - -`list`方法将使用 fetch 进行`GET`调用,以检索数据库中的所有用户,然后将服务器的响应作为承诺返回给组件。 - -`mern-skeleton/client/user/api-user.js`: - -```jsx -const list = () => { - return fetch('/api/users/', { - method: 'GET', - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 读取用户配置文件 - -`read`方法将使用 fetch 进行`GET`调用,以按 ID 检索特定用户。由于这是受保护的路由,除了将用户 ID 作为参数传递外,请求组件还必须提供有效凭据,在这种情况下,该凭据将是成功登录后接收到的有效 JWT。 - -`mern-skeleton/client/user/api-user.js`: - -```jsx -const read = (params, credentials) => { - return fetch('/api/users/' + params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -JWT 使用`Bearer`方案连接到`Authorization`报头中的`GET`fetch 调用,然后服务器的响应以承诺的形式返回给组件。 - -# 更新用户的数据 - -`update`方法将从特定用户的视图组件中获取已更改的用户数据,然后使用`fetch`进行`PUT`调用以更新后端的现有用户。这也是一个受保护的路由,需要有效的 JWT 作为凭据。 - -`mern-skeleton/client/user/api-user.js`: - -```jsx -const update = (params, credentials, user) => { - return fetch('/api/users/' + params.userId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(user) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 删除用户 - -`remove`方法将允许视图组件从数据库中删除特定用户,使用 fetch 进行`DELETE`调用。这也是一个受保护的路由,需要有效的 JWT 作为凭证,类似于`read`和`update`方法。服务器对删除请求的响应将作为承诺返回给组件。 - -`mern-skeleton/client/user/api-user.js`: - -```jsx -const remove = (params, credentials) => { - return fetch('/api/users/' + params.userId, { - method: 'DELETE', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -最后,根据需要导出 React 组件要导入和使用的用户 API 帮助器方法。 - -`mern-skeleton/client/user/api-user.js`: - -```jsx -export { create, list, read, update, remove } -``` - -# 获取身份验证 API - -为了将来自服务器的 auth API 端点与前端 React 组件集成,我们将在`client/auth/api-auth.js`文件中添加获取登录和注销 API 端点的方法。 - -# 登录 - -`signin`方法将从视图组件获取用户登录数据,然后使用`fetch`进行`POST`调用,用后端验证用户。来自服务器的响应将返回到 promise 中的组件,如果登录成功,该组件可能包含 JWT。 - -`mern-skeleton/client/user/api-auth.js`: - -```jsx -const signin = (user) => { - return fetch('/auth/signin/', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - credentials: 'include', - body: JSON.stringify(user) - }) - .then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 注销 - -`signout`方法将使用 fetch 对服务器上的注销 API 端点进行 GET 调用。 - -`mern-skeleton/client/user/api-auth.js`: - -```jsx -const signout = () => { - return fetch('/auth/signout/', { - method: 'GET', - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -在`api-auth.js`文件的末尾,导出`signin`和`signout`方法。 - -`mern-skeleton/client/user/api-auth.js`: - -```jsx -export { signin, signout } -``` - -使用这些 API 获取方法,React 前端可以完全访问后端中可用的端点。 - -# 在前端进行身份验证 - -如前一章所述,使用 JWT 实现身份验证将把管理和存储用户身份验证状态的责任移交给客户端。为此,我们需要编写代码,允许客户端在成功登录时存储从服务器接收的 JWT,在访问受保护路由时使其可用,在用户注销时删除或使令牌无效,并根据用户身份验证状态限制对前端视图和组件的访问。 - -使用 React Router 文档中的身份验证工作流示例,我们将编写帮助器方法来管理组件间的身份验证状态,并使用自定义`PrivateRoute`组件将受保护的路由添加到前端。 - -# 管理身份验证状态 - -在`client/auth/auth-helper.js`中,我们将定义以下助手方法来存储和检索客户端`sessionStorage`中的 JWT 凭证,并在用户注销时清除`sessionStorage`: - -* `authenticate(jwt, cb)`:成功登录时保存凭证: - -```jsx -authenticate(jwt, cb) { - if(typeof window !== "undefined") - sessionStorage.setItem('jwt', JSON.stringify(jwt)) - cb() -} -``` - -* `isAuthenticated()`:如果已登录,则检索凭据: - -```jsx -isAuthenticated() { - if (typeof window == "undefined") - return false - - if (sessionStorage.getItem('jwt')) - return JSON.parse(sessionStorage.getItem('jwt')) - else - return false -} -``` - -* `signout(cb)`:删除凭证并注销: - -```jsx -signout(cb) { - if(typeof window !== "undefined") - sessionStorage.removeItem('jwt') - cb() - signout().then((data) => { - document.cookie = "t=; expires=Thu, 01 Jan 1970 00:00:00 - UTC; path=/;" - }) -} -``` - -使用此处定义的方法,我们构建的 React 组件将能够检查和管理用户身份验证状态,以限制前端的访问,如以下自定义`PrivateRoute`所示。 - -# 专用路由组件 - -`client/auth/PrivateRoute.js`定义了`PrivateRoute`组件,如[中的验证流示例所示 https://reacttraining.com/react-router/web/example/auth-workflow](https://reacttraining.com/react-router/web/example/auth-workflow) 在 React 路由文档中。它将允许我们为前端声明受保护的路由,以限制基于用户身份的视图访问。 - -`mern-skeleton/client/auth/PrivateRoute.js`: - -```jsx -import React, { Component } from 'react' -import { Route, Redirect } from 'react-router-dom' -import auth from './auth-helper' - -const PrivateRoute = ({ component: Component, ...rest }) => ( - ( - auth.isAuthenticated() ? ( - - ) : ( - - ) - )}/> -) - -export default PrivateRoute -``` - -此`PrivateRoute`中要呈现的组件只有在用户经过身份验证后才会加载,否则用户将被重定向到`Signin`组件。 - -集成了后端 API 和组件中可以使用的身份验证管理助手方法后,我们可以开始构建其余的视图组件。 - -# 用户和身份验证组件 - -本节中描述的 React 组件通过允许用户查看、创建和修改数据库中存储的与身份验证限制相关的用户数据,完成了为骨架定义的交互功能。对于以下每个组件,我们将在`MainRouter`中介绍每个组件的独特方面,以及如何将组件添加到应用中。 - -# 用户组件 - -`client/user/Users.js`中的`Users`组件显示从数据库中获取的所有用户的名称,并将每个名称链接到用户配置文件。此组件可供应用的任何访问者查看,并将在路径`'/users'`处呈现: - -![](img/896050db-1fcc-47db-b597-a90f8af964a1.png) - -在组件定义中,我们首先使用空的用户数组初始化状态。 - -`mern-skeleton/client/user/Users.js`: - -```jsx -class Users extends Component { - state = { users: [] } -... -``` - -接下来,在`componentDidMount`中,我们使用`api-user.js`助手方法中的`list`方法,从后端获取用户列表,并通过更新状态将用户数据加载到组件中。 - -`mern-skeleton/client/user/Users.js`: - -```jsx - componentDidMount = () => { - list().then((data) => { - if (data.error) - console.log(data.error) - else - this.setState({users: data}) - }) - } -``` - -`render`功能包含`Users`组件的实际查看内容,由`Paper`、`List`、`ListItems`等物料 UI 组件组成。元素的样式由 CSS 定义并作为道具传入。 - -`mern-skeleton/client/user/Users.js`: - -```jsx -render() { - const {classes} = this.props - return ( - - - All Users - - - {this.state.users.map(function(item, i) { - return - - - - - - - - - - - - - - - })} - - - ) - } -``` - -为了生成每个列表项,我们使用 map 函数遍历状态中的用户数组。 - -要将这个`Users`组件添加到 React 应用中,我们需要用一个`Route`来更新`MainRouter`组件,该`Route`将这个组件呈现在`'/users'`路径上。在`Home`路径后的`Switch`组件中添加`Route`。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx - -``` - -要查看浏览器中呈现的此视图,可以临时在`Home`组件中添加`Link`组件,以路由到`Users`组件: - -```jsx -Users -``` - -# 注册组件 - -`client/user/Signup.js`中的`Signup`组件向用户提供一个包含姓名、电子邮件和密码字段的表单,供用户在`'/signup'`路径注册: - -![](img/b478e453-909d-4348-af22-0a44ac3ff566.png) - -在组件定义中,我们首先使用空的输入字段值、空的错误消息初始化状态,然后将 dialog open 变量设置为 false。 - -`mern-skeleton/client/user/Signup.js`: - -```jsx - constructor() { - state = { name: '', password: '', email: '', open: false, error: '' } - ... -``` - -我们还定义了当输入值更改或单击 submit 按钮时要调用的两个处理程序函数。`handleChange`函数接受输入字段中输入的新值,并将其设置为`state`。 - -`mern-skeleton/client/user/Signup.js`: - -```jsx -handleChange = name => event => { - this.setState({[name]: event.target.value}) -} -``` - -提交表单时调用`clickSubmit`函数。它从 state 获取输入值,并调用`create`fetch 方法向后端注册用户。然后,根据服务器的响应,显示错误消息或成功对话框。 - -`mern-skeleton/client/user/Signup.js`: - -```jsx - clickSubmit = () => { - const user = { - name: this.state.name || undefined, - email: this.state.email || undefined, - password: this.state.password || undefined - } - create(user).then((data) => { - if (data.error) - this.setState({error: data.error}) - else - this.setState({error: '', open: true}) - }) - } -``` - -在`render`函数中,我们使用 Material UI 中的`TextField`等组件在注册视图中组合表单组件并设置其样式。 - -`mern-skeleton/client/user/Signup.js`: - -```jsx - render() { - const {classes} = this.props - return (
- - - - Sign Up - -
-
-
- {this.state.error && ( - error - {this.state.error})} -
- - - -
- ... -
) - } -``` - -呈现还包含一个错误消息块和一个`Dialog`组件,该组件根据服务器的注册响应有条件地呈现。`Signup.js`中的`Dialog`组件组成如下。 - -`mern-skeleton/client/user/Signup.js`: - -```jsx - - New Account - - - New account successfully created. - - - - - - - - -``` - -成功创建帐户后,用户将得到确认,并要求用户使用此`Dialog`组件登录,该组件链接到`Signin`组件: - -![](img/fc581278-d930-463d-af2b-d9fad6cc69a0.png) - -要将`Signup`组件添加到应用中,请将以下`Route`添加到`Switch`组件中的`MainRouter`中。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx - -``` - -这将在`'/signup'`处呈现`Signup`视图。 - -# 符号成分 - -`client/auth/Signin.js`中的`Signin`组件也是一个表单,只有用于登录的电子邮件和密码字段。该组件与`Signup`组件非常相似,将在`'/signin'`路径上呈现。关键区别在于成功登录并存储接收到的 JWT 后实现重定向: - -![](img/590ab772-2a8f-431e-94ab-7c4fe4a57620.png) - -对于重定向,我们将使用 React Router 的`Redirect`组件。首先,使用其他字段将状态中的`redirectToReferrer`值初始化为`false`: - -`mern-skeleton/client/auth/Signin.js`: - -```jsx -class Signin extends Component { - state = { email: '', password: '', error: '', redirectToReferrer: false } -... -``` - -当用户提交表单后成功登录且接收到的 JWT 存储在`sessionStorage`中时,应将`redirectToReferrer`设置为`true`。为了存储 JWT 和重定向后缀,我们将调用`auth-helper.js`中定义的`authenticate()`方法。此代码将进入`clickSubmit()`函数,在表单提交时调用。 - -`mern-skeleton/client/auth/Signin.js`: - -```jsx -clickSubmit = () => { - const user = { - email: this.state.email || undefined, - password: this.state.password || undefined - } - signin(user).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - auth.authenticate(data, () => { - this.setState({redirectToReferrer: true}) - }) - } - }) -} -``` - -重定向将基于`render`函数中的`Redirect`组件的`redirectToReferrer`值有条件地发生。在返回之前在 render 函数中添加重定向代码,如下所示: - -`mern-skeleton/client/auth/Signin.js`: - -```jsx -render() { - const {classes} = this.props - const {from} = this.props.location.state || { - from: {pathname: '/' } - } - const {redirectToReferrer} = this.state - if (redirectToReferrer) - return () - return (...) - } -} -``` - -如果呈现`Redirect`组件,则会将应用带到最后一个位置或根位置的`Home`组件 - -返回将包含类似于`Signup`的表单元素,只有`email`和`password`字段、条件错误消息和`submit`按钮。 - -要将`Signin`组件添加到应用中,请将以下路由添加到`Switch`组件中的`MainRouter`。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx - -``` - -这将在`"/signin"`处呈现`Signin`组件。 - -# 轮廓组件 - -`client/user/Profile.js`中的`Profile`组件在`'/user/:userId'`路径的视图中显示单个用户的信息,`userId`参数表示特定用户的 ID: - -![](img/cb24bc18-4a74-4041-b29f-ab6635c68723.png) - -只有在用户登录的情况下,才能从服务器获取此配置文件信息,为了验证这一点,组件必须向`read`获取调用提供 JWT,否则,应将用户重定向到登录视图。 - -在`Profile`组件定义中,我们首先需要使用空用户初始化状态,并将`redirectToSignin`设置为`false`。 - -`mern-skeleton/client/user/Profile.js`: - -```jsx -class Profile extends Component { - constructor({match}) { - super() - this.state = { user: '', redirectToSignin: false } - this.match = match - } ... -``` - -我们还需要访问`Route`组件传递的 match 道具,该道具将包含`:userId`参数值,并且在组件安装时可以作为`this.match.params.userId`访问。 - -`Profile`组件应获取用户信息,并在`userId`参数在路由中发生变化时呈现。但是,当应用从一个纵断面图转到另一个纵断面图,并且只是路由路径中的参数更改时,React 组件不会重新装载。而是通过`componentWillReceiveProps`中的新道具。为了确保组件在路由参数更新时加载相关的用户信息,我们将在`init()`函数中放置`read`fetch 调用,然后在`componentDidMount`和`componentWillReceiveProps`中都可以调用该函数。 - -`mern-skeleton/client/user/Profile.js`: - -```jsx -init = (userId) => { - const jwt = auth.isAuthenticated() - read({ - userId: userId - }, {t: jwt.token}).then((data) => { - if (data.error) - this.setState({redirectToSignin: true}) - else - this.setState({user: data}) - }) -} -``` - -`init(userId)`函数取`userId`值,并调用读取用户获取方法。由于此方法还需要凭据来授权登录用户,因此使用`auth-helper.js`中的`isAuthenticated`方法从`sessionStorage`检索 JWT。服务器响应后,状态将使用用户信息更新,或者视图将重定向到登录视图。 - -在`componentDidMount`和`componentWillReceiveProps`中调用此`init`函数,并将相关的`userId`值作为参数传入,以便在组件中获取并加载正确的用户信息。 - -`mern-skeleton/client/user/Profile.js`: - -```jsx -componentDidMount = () => { - this.init(this.match.params.userId) -} -componentWillReceiveProps = (props) => { - this.init(props.match.params.userId) -} -``` - -在`render`函数中,我们设置条件重定向到 Signin 视图,并返回`Profile`视图的内容: - -`mern-skeleton/client/user/Profile.js` - -```jsx -render() { - const {classes} = this.props - const redirectToSignin = this.state.redirectToSignin - if (redirectToSignin) - return - return (...) - } -``` - -如果当前登录的用户正在查看其他用户的个人资料,`render`函数将返回包含以下元素的`Profile`视图。 - -`mern-skeleton/client/user/Profile.js`: - -```jsx -
- - Profile - - - - - - - - - - - - - - - -
-``` - -但是,如果当前登录的用户正在查看自己的个人资料,他们将能够在`Profile`组件中看到编辑和删除选项,如以下屏幕截图所示: - -![](img/a262128f-e3dd-41d9-b1ea-584aa9ffa927.png) - -为了实现此功能,在`Profile`中的第一个`ListItem`组件中,添加一个包含`Edit`按钮的`ListItemSecondaryAction`组件和一个`DeleteUser`组件,该组件将根据当前用户是否正在查看自己的个人资料进行有条件的渲染。 - -`mern-skeleton/client/user/Profile.js`: - -```jsx -{ auth.isAuthenticated().user && auth.isAuthenticated().user._id == this.state.user._id && - ( - - - - - - - )} -``` - -`Edit`按钮将路由到`EditProfile`组件,这里使用的自定义`DeleteUser`组件将处理删除操作,并将`userId`作为道具传递给它。 - -要将`Profile`组件添加到应用中,请将`Route`添加到`Switch`组件中的`MainRouter`。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx - -``` - -# 编辑配置文件组件 - -`client/user/EditProfile.js`中的`EditProfile`组件在实现上与`Signup`和`Profile`组件都有相似之处。它将允许授权用户以类似于注册表格的形式编辑自己的个人资料信息: - -![](img/0927ca58-eb21-4f74-97e9-fcc0d81d0943.png) - -当在`'/user/edit/:userId'`加载时,组件将在验证 JWT 是否为 auth 后获取 ID 为的用户信息,然后使用接收到的用户信息加载表单。表单将允许用户编辑并仅向`update`获取调用提交更改的信息,并且在成功更新后,将用户重定向到具有更新信息的`Profile`视图。 - -`EditProfile`将以与`Profile`组件中相同的方式加载用户信息,方法是使用`this.match.params`中的`userId`参数和`auth.isAuthenticated`中的凭证在`componentDidMount`中使用`read`进行获取。表单视图将具有与`Signup`组件相同的元素,输入值在更改状态下更新。 - -在表单提交时,组件将使用`userId`、JWT 和更新的用户数据调用`update`获取方法。 - -`mern-skeleton/client/user/EditProfile.js`: - -```jsx -clickSubmit = () => { - const jwt = auth.isAuthenticated() - const user = { - name: this.state.name || undefined, - email: this.state.email || undefined, - password: this.state.password || undefined - } - update({ - userId: this.match.params.userId - }, { - t: jwt.token - }, user).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({'userId': data._id, 'redirectToProfile': true}) - } - }) -} -``` - -根据服务器的响应,用户将看到错误消息或被重定向到更新的配置文件页面,其中呈现函数中包含以下`Redirect`组件。 - -`mern-skeleton/client/user/EditProfile.js`: - -```jsx -if (this.state.redirectToProfile) - return () -``` - -要将`EditProfile`组件添加到应用中,我们这次将使用`PrivateRoute`来限制在用户未登录的情况下加载组件。`MainRouter`中的放置顺序也很重要。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx - - ... <> - - -``` - -路径为`'/user/edit/:userId'`的路由需要放在路径为`'/user/:userId'`的路由之前,这样,当请求该路由时,编辑路径首先在交换机组件中唯一匹配,而不会与`Profile`路由混淆 - -# 删除用户组件 - -`client/user/DeleteUser.js`中的`DeleteUser`组件基本上是一个我们将添加到纵断面图中的按钮,点击后会打开一个`Dialog`组件,要求用户确认`delete`动作: - -![](img/9107c796-0e36-4350-9124-4bcb51662b99.png) - -组件首先初始化状态,`Dialog`组件的`open`设置为`false`,而`redirect`也设置为`false`,因此不会首先渲染。 - -`mern-skeleton/client/user/DeleteUser.js`: - -```jsx -class DeleteUser extends Component { - state = { redirect: false, open: false } -... -``` - -接下来,我们需要处理程序方法来打开和关闭`dialog`按钮。当用户点击`delete`按钮时,对话框打开。 - -`mern-skeleton/client/user/DeleteUser.js`: - -```jsx -clickButton = () => { - this.setState({open: true}) -} -``` - -当用户点击对话框`cancel`时,对话框关闭。 - -`mern-skeleton/client/user/DeleteUser.js`: - -```jsx - handleRequestClose = () => { - this.setState({open: false}) - } -``` - -当用户在对话框中确认`delete`动作后,组件将有权访问`Profile`组件作为道具传入的`userId`,该道具需要与 JWT 一起调用`remove`获取方法。 - -`mern-skeleton/client/user/DeleteUser.js`: - -```jsx -deleteAccount = () => { - const jwt = auth.isAuthenticated() - remove({ - userId: this.props.userId - }, {t: jwt.token}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - auth.signout(() => console.log('deleted')) - this.setState({redirect: true}) - } - }) - } -``` - -确认后,`deleteAccount`函数调用`remove`获取方法,其中`userId`来自 props,JWT 来自`isAuthenticated`。在服务器中成功删除后,用户将注销并重定向到主视图。 - -渲染函数包含条件`Redirect`到主视图,并返回`DeleteUser`组件元素、一个`DeleteIcon`按钮和确认`Dialog`: - -`mern-skeleton/client/user/DeleteUser.js`: - -```jsx -render() { - const redirect = this.state.redirect - if (redirect) { - return - } - return ( - - - - - {"Delete Account"} - - - Confirm to delete your account. - - - - - - - - ) -} -``` - -`DeleteUser`将`userId`作为在`delete`获取调用中使用的道具,因此我们为所需道具`userId`添加了`propType`检查。 - -`mern-skeleton/client/user/DeleteUser.js`: - -```jsx -DeleteUser.propTypes = { - userId: PropTypes.string.isRequired -} -``` - -当我们在`Profile`组件中使用`DeleteUser`组件时,当`MainRouter`中添加`Profile`时,它会被添加到应用视图中。 - -# 菜单组件 - -`Menu`组件将通过提供指向所有可用视图的链接,在前端应用中充当导航栏,并指示应用中的当前位置。 - -为了实现这些导航栏功能,我们将使用 React 路由的 HOC`withRouter`来访问历史对象的属性。`Menu`组件中的以下代码仅添加标题、`Home`图标链接到根路由以及`Users`按钮链接到`'/users'`路由。 - -`mern-skeleton/client/core/Menu.js`: - -```jsx -const Menu = withRouter(({history}) => (
- - - - MERN Skeleton - - - - - - - - - - - -
)) -``` - -为了在`Menu`上指示应用的当前位置,我们将通过有条件地更改颜色来突出显示与当前位置路径匹配的链接。 - -`mern-skeleton/client/core/Menu.js`: - -```jsx -const isActive = (history, path) => { - if (history.location.pathname == path) - return {color: '#ff4081'} - else - return {color: '#ffffff'} -} -``` - -`isActive`功能用于给`Menu`中的按钮上色,如下所示: - -```jsx -style={isActive(history, "/users")} -``` - -其余链接,如登录、注册、我的个人资料和注销,将根据用户是否登录显示在`Menu`上: - -![](img/0771a0a9-9fbc-4b22-b1d7-4b195a068d13.png) - -例如,只有当用户未登录时,登录和登录的链接才应显示在菜单上。所以我们需要在`Users`按钮后面添加一个条件,将其添加到`Menu`组件中。 - -`mern-skeleton/client/core/Menu.js`: - -```jsx -{!auth.isAuthenticated() && ( - - - - - - -)} -``` - -类似地,`MY PROFILE`的链接和`SIGN OUT`按钮只应在用户登录时显示在菜单上,并应通过此条件检查添加到`Menu`组件中。 - -`mern-skeleton/client/core/Menu.js`: - -```jsx -{auth.isAuthenticated() && ( - - - - - )} -``` - -`MY PROFILE`按钮使用登录用户的信息链接到用户自己的个人资料,`SIGN OUT`按钮在点击时调用`auth.signout()`方法。用户登录后,菜单将如下所示: - -![](img/0cab70a8-b7d1-461c-93f7-6ba69b4281af.png) - -要使`Menu`导航栏出现在所有视图中,我们需要将其添加到`MainRouter`中,然后再添加到所有其他路线中,并添加到`Switch`组件之外。 - -`mern-skeleton/client/MainRouter.js`: - -```jsx - - - … - -``` - -这将使`Menu`组件在路径上访问时呈现在所有其他组件之上。 - -框架前端包含所有必要的组件,使用户能够在后端注册、查看和修改用户数据,同时考虑身份验证和授权限制。但是,仍然无法在浏览器地址栏中直接访问前端管线,只能在从前端视图中链接时访问。要在骨架应用中启用此功能,我们需要实现基本的服务器端渲染。 - -# 基本服务器端渲染 - -当前,如果在浏览器地址栏中直接输入 React 路由路由或路径名,或者刷新不在根路径上的视图,则 URL 不起作用。这是因为服务器无法识别 React 路由路由。我们必须在后端实现基本的服务器端渲染,以便服务器能够在接收到前端路由请求时做出响应。 - -为了在服务器接收到对前端路由的请求时正确地呈现相关的 React 组件,我们需要在服务器端呈现 React 路由和 materialui 组件的 React 组件。 - -React 应用服务器端呈现的基本思想是使用`react-dom`中的`renderToString`方法将根 React 组件转换为标记字符串,并将其附加到服务器收到请求时呈现的模板。 - -在`express.js`中,我们将用代码替换响应`'/'`的`GET`请求返回`template.js`的代码,该代码在接收到任何传入 GET 请求时,生成相关 React 组件的服务器端呈现标记,并将该标记添加到模板中。该代码将具有以下结构: - -```jsx -app.get('*', (req, res) => { - // 1\. Prepare Material-UI styles - // 2\. Generate markup with renderToString - // 3\. Return template with markup and CSS styles in the response -}) -``` - -# 服务器端渲染模块 - -为了实现基本的服务器端渲染,我们需要将以下 React、React 路由和 materialui 特定模块导入服务器代码。在我们的代码结构中,这些模块将被导入到`server/express.js`中: - -* **反应模块**:需要渲染反应组件并使用`renderToString`: - -```jsx -import React from 'react' -import ReactDOMServer from 'react-dom/server' -``` - -* **路由模块**:`StaticRouter`是一个无状态路由,它使用请求的 URL 匹配前端路由和`MainRouter`组件,后者是我们前端的根组件: - -```jsx -import StaticRouter from 'react-router-dom/StaticRouter' -import MainRouter from './../client/MainRouter' -``` - -* **材质 UI 模块**:以下模块将根据前端使用的材质 UI 主题,帮助生成前端组件的 CSS 样式: - -```jsx -import { SheetsRegistry } from 'react-jss/lib/jss' -import JssProvider from 'react-jss/lib/JssProvider' -import { MuiThemeProvider, createMuiTheme, createGenerateClassName } from 'material-ui/styles' -import { indigo, pink } from 'material-ui/colors' -``` - -使用这些模块,我们可以准备、生成和返回服务器端呈现的前端代码。 - -# 为 SSR 准备材料 UI 样式 - -当服务器接收到任何请求时,在使用包含 React 视图的生成标记进行响应之前,我们需要准备 CSS 样式,这些样式也应该添加到标记中,以便 UI 在初始渲染时不会中断。 - -`mern-skeleton/server/express.js`: - -```jsx -const sheetsRegistry = new SheetsRegistry() -const theme = createMuiTheme({ - palette: { - primary: { - light: '#757de8', - main: '#3f51b5', - dark: '#002984', - contrastText: '#fff', - }, - secondary: { - light: '#ff79b0', - main: '#ff4081', - dark: '#c60055', - contrastText: '#000', - }, - openTitle: indigo['400'], - protectedTitle: pink['400'], - type: 'light' - }, -}) -const generateClassName = createGenerateClassName() -``` - -为了注入材料 UI 样式,在每个请求中,我们首先生成一个新的`SheetsRegistry`和 MUI 主题实例,匹配前端代码中使用的内容。 - -# 生成标记 - -使用`renderToString`的目的是生成 React 组件的 HTML 字符串版本,该版本将显示给用户,以响应请求的 URL: - -`mern-skeleton/server/express.js`: - -```jsx -const context = {} -const markup = ReactDOMServer.renderToString( - - - - - - - -) -``` - -客户端应用的根组件`MainRouter`用材质 UI 主题和 JS 包装,以提供`MainRouter`子组件所需的样式道具。这里使用无状态的`StaticRouter`代替客户端使用的`BrowserRouter`,包装`MainRouter`并提供用于实现客户端组件的路由道具。基于这些值,`renderToString`将返回包含相关视图的标记,例如请求的`location`路线和作为道具传递给包装组件的主题。 - -# 发送带有标记和 CSS 的模板 - -生成标记后,我们首先检查组件中是否有一个`redirect`呈现,以便在标记中发送。如果没有重定向,那么我们将从`sheetsRegistry`生成 CSS 字符串,并在响应中发送带有标记和 CSS 注入的模板 - -`mern-skeleton/server/express.js`: - -```jsx -if (context.url) { - return res.redirect(303, context.url) -} -const css = sheetsRegistry.toString() -res.status(200).send(Template({ - markup: markup, - css: css -})) -``` - -组件中呈现重定向的一个示例是,尝试通过服务器端呈现访问`PrivateRoute`时。由于服务器端无法从客户端`sessionStorage`访问 auth 令牌,`PrivateRoute`中的重定向将呈现。在本例中,`context.url`将具有`'/signin'`路由,因此它将重定向到`'/signin'`路由,而不是尝试呈现`PrivateRoute`组件。 - -# 更新 template.js - -服务器上生成的标记和 CSS 必须添加到`template.js`HTML 代码中,如下所示,以便在服务器呈现模板时加载 - -`mern-skeleton/template.js`: - -```jsx -export default ({markup, css}) => { - return `... -
${markup}
- - ...` -} -``` - -# 更新主路由 - -一旦服务器端呈现的代码到达浏览器,前端脚本接管,我们需要在主组件挂载时删除服务器端注入的 CSS。这将返回对向客户端呈现 React 应用的完全控制: - -`mern-skeleton/client/MainRouter.js`: - -```jsx -componentDidMount() { - const jssStyles = document.getElementById('jss-server-side') - if (jssStyles && jssStyles.parentNode) - jssStyles.parentNode.removeChild(jssStyles) -} -``` - -# 使用水合物代替渲染 - -现在 React 组件将在服务器端呈现,我们可以将`main.js`代码更新为使用`ReactDOM.hydrate()`而不是`ReactDOM.render()`: - -```jsx -import React from 'react' -import { hydrate } from 'react-dom' -import App from './App' - -hydrate(, document.getElementById('root')) -``` - -`hydrate`函数对已经有`ReactDOMServer`呈现的 HTML 内容的容器进行水合物化处理。这意味着服务器呈现的标记将被保留,并且当 React 在浏览器中接管时,仅附加事件处理程序,从而使初始加载性能更好。 - -实现了基本的服务器端渲染后,服务器现在可以正确处理从浏览器地址栏到前端路由的直接请求,从而可以将 React 前端视图添加到书签中。 - -这里开发的框架 MERN 应用现在是一个功能完整的 MERN web 应用,具有基本的用户功能。我们可以扩展此框架中的代码,为不同的应用添加各种功能 - -# 总结 - -在本章中,我们通过添加一个工作的 React 前端来完成 MERN skeleton 应用,包括前端路由和 React 视图的基本服务器端呈现。 - -我们首先更新了开发流程,以包含 React 视图的客户端代码绑定。我们更新了 Webpack 和 Babel 的配置以编译 React 代码,并讨论了如何从 Express app 加载已配置的 Webpack 中间件,以便在开发过程中从一个位置启动服务器端和客户端代码编译。 - -随着开发流程的更新,在构建前端之前,我们添加了相关的 React 依赖项以及 React Router for frontend routing 和 Material UI,以使用骨架应用用户界面中的现有组件 - -然后,我们实现了顶级根 React 组件,并集成了 React 路由,使我们能够添加用于导航的客户端路由。使用这些路由,我们加载了使用 Material UI 组件开发的定制 React 组件,以构成骨架应用的用户界面。 - -为了使这些 React 视图动态且与从后端获取的数据交互,我们使用 Fetch API 连接到后端用户 API。然后,我们在前端视图上加入了身份验证和授权,使用`sessionStorage`存储用户特定的详细信息和成功登录时从服务器获取的 JWT,并使用`PrivateRoute`组件限制对某些视图的访问 - -最后,我们修改了服务器代码以实现基本的服务器端呈现,允许在服务器识别到传入的请求实际上是针对 React 路由之后,使用服务器端呈现的标记直接在浏览器中加载前端路由 - -在下一章中,我们将使用在开发这个基本的 MERN 应用时学到的概念,并扩展框架应用代码以构建一个功能齐全的社交媒体应用。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/05.md b/docs/full-stk-react-proj/05.md deleted file mode 100644 index 1a958a8..0000000 --- a/docs/full-stk-react-proj/05.md +++ /dev/null @@ -1,1806 +0,0 @@ -# 五、从一个简单的社交媒体应用开始 - -如今,社交媒体已成为 web 不可或缺的一部分,我们构建的许多以用户为中心的 web 应用最终都需要社交组件来推动用户参与。 - -对于我们的第一个真实世界的 MERN 应用,我们将修改和扩展上一章中开发的 MERN 骨架应用,以构建一个简单的社交媒体应用。 - -在本章中,我们将介绍以下社交媒体特色的实现: - -* 带有说明和照片的用户配置文件 -* 用户相互跟踪 -* 谁来遵循这些建议 -* 发布带有照片的消息 -* 带有跟踪用户帖子的新闻提要 -* 按用户列出帖子 -* 喜欢的帖子 -* 评论帖子 - -# 梅恩社会 - -MERN Social 是一款社交媒体应用,其基本功能源自 Facebook 和 Twitter 等现有社交媒体平台。此应用的主要目的是演示如何使用 MERN 堆栈技术实现允许用户通过内容连接和交互的功能。您可以根据需要进一步扩展这些实现,以实现更复杂的功能: - -![](img/677fef10-8bc2-489d-bccd-c58aa24901af.png) - -Code for the complete MERN Social application is available on GitHub in the repository at [github.com/shamahoque/mern-social](https://github.com/shamahoque/mern-social). You can clone this code and run the application as you go through the code explanations in the rest of this chapter. - -MERN 社交应用所需的视图将通过扩展和修改 MERN 骨架应用中现有的 React 组件来开发。我们还将添加新的自定义组件来组合视图,包括一个 Newsfeed 视图,用户可以在其中创建新帖子,还可以浏览他们关注 MERN Social 的人的所有帖子的列表。下面的组件树显示了组成 MERN Social 前端的所有自定义 React 组件,还公开了我们将用于构建其余部分中的视图的合成结构第章: - -![](img/e782c1f5-ce25-46b7-a015-256e6c5017e8.jpg) - -# 更新用户配置文件 - -骨架应用只支持用户名、电子邮件和密码。但在 MERN Social 中,我们允许用户添加关于自己的描述,并在注册后编辑个人资料时上传个人资料照片: - -![](img/d14f75f1-492f-4e8f-aa1c-2d42644348bc.png) - -# 添加关于描述 - -为了存储用户在`about`字段中输入的描述,我们需要在`server/models/user.model.js`中的用户模型中添加一个`about`字段: - -```jsx -about: { - type: String, - trim: true - } -``` - -然后,为了从用户那里获得作为输入的描述,我们在`EditProfile`表单中添加了一个多行`TextField`,并以与用户名输入相同的方式处理值更改。 - -`mern-social/client/user/EditProfile.js`: - -```jsx - -``` - -最后,为了显示添加到用户配置文件页面`about`字段的描述文本,我们可以将其添加到现有的配置文件视图中。 - -`mern-social/client/user/Profile.js`: - -```jsx - -``` - -通过对 MERN 骨架代码中用户特性的修改,用户现在可以添加和更新关于他们自己的描述,以显示在他们的配置文件中。 - -# 上传个人资料照片 - -允许用户上传个人资料照片需要我们存储上传的图像文件,并根据请求将其检索到视图中加载。考虑到不同的文件存储选项,有多种实现此上载功能的方法: - -* **服务器文件系统**:将文件上传并保存到服务器文件系统,并将 URL 存储到 MongoDB -* **外部文件存储**:将文件保存到 Amazon S3 等外部存储,并将 URL 存储在 MongoDB 中 -* **在 MongoDB 中存储为数据**:将小文件(小于 16MB)作为缓冲区类型的数据保存到 MongoDB 中 - -对于 MERN Social,我们将假设用户上传的照片文件大小较小,并演示如何将这些文件存储在 MongoDB 中,以实现个人资料照片上传功能。在[第 8 章](08.html)、*构建流媒体应用*中,我们将讨论如何使用 GridFS 在 MongoDB 中存储较大的文件。 - -# 更新用户模型以在 MongoDB 中存储照片 - -为了将上传的个人资料照片直接存储在数据库中,我们将更新用户模型,添加一个`photo`字段,该字段将文件存储为`Buffer`类型的`data`及其`contentType`。 - -`mern-social/server/models/user.model.js`: - -```jsx -photo: { - data: Buffer, - contentType: String -} -``` - -# 从编辑表单上载照片 - -用户可以在编辑配置文件时从本地文件上载图像文件。我们将使用上传照片选项更新`client/user/EditProfile.js`中的`EditProfile`组件,然后将用户选择的文件附加到提交给服务器的表单数据中。 - -# 使用物料界面进行文件输入 - -我们将利用 HTML5 文件输入类型,让用户从本地文件中选择图像。当用户选择文件时,文件输入将在更改事件中返回文件名。 - -`mern-social/client/user/EditProfile.js`: - -```jsx - -``` - -为了将此文件`input`与物料 UI 组件集成,我们应用`display:none`从视图中隐藏`input`元素,然后在标签内添加物料 UI 按钮,用于此文件输入。这样,视图将显示 Material UI 按钮,而不是 HTML5 文件输入元素。 - -`mern-social/client/user/EditProfile.js`: - -```jsx - -``` - -当`Button`的组件属性设置为`span`时,`Button`组件呈现为`label`元素内部的`span`元素。点击`Upload`跨距或标签时,文件输入会以与标签相同的 ID 注册,因此,文件选择对话框会打开。一旦用户选择了一个文件,我们可以在调用`handleChange(...)`时将其设置为状态,并在视图中显示名称。 - -`mern-social/client/user/EditProfile.js`: - -```jsx - - {this.state.photo ? this.state.photo.name : ''} - -``` - -# 随附文件的表格提交 - -与上一个实现中发送的`stringed`对象不同,使用表单将文件上载到服务器需要提交多部分表单。我们将修改`EditProfile`组件,使用`FormData`API 以编码类型`multipart/form-data`所需的格式存储表单数据。 - -首先,我们需要在`componentDidMount()`中初始化`FormData`。 - -`mern-social/client/user/EditProfile.js`: - -```jsx -this.userData = new FormData() -``` - -接下来,我们将更新输入`handleChange`函数,将文本字段和文件输入的输入值存储在`FormData`中。 - -`mern-social/client/user/EditProfile.js`: - -```jsx -handleChange = name => event => { - const value = name === 'photo' - ? event.target.files[0] - : event.target.value - this.userData.set(name, value) - this.setState({ [name]: value }) -} -``` - -然后在提交时,`this.userData`与 fetch API 调用一起发送,以更新用户。由于发送到服务器的数据的内容类型不再是`'application/json'`,我们还需要修改`api-user.js`中的`update`fetch 方法,将`Content-Type`从`fetch`调用的头中删除。 - -`mern-social/client/user/api-user.js`: - -```jsx -const update = (params, credentials, user) => { - return fetch('/api/users/' + params.userId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: user - }).then((response) => { - return response.json() - }).catch((e) => { - console.log(e) - }) -} -``` - -现在,如果用户在编辑配置文件时选择上载配置文件照片,服务器将收到一个请求,其中包含附加的文件以及其他字段值。 - -Learn more about the FormData API at [developer.mozilla.org/en-US/docs/Web/API/FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData). - -# 处理包含文件上载的请求 - -在服务器上,为了处理对更新 API 的请求,现在可能包含一个文件,我们将使用`formidable`npm 模块: - -```jsx -npm install --save formidable -``` - -强大将允许我们读取`multipart`表单数据,允许访问字段和文件(如果有的话)。如果有文件,`formidable`将临时存储在文件系统中。我们将从文件系统中读取它,使用`fs`模块检索文件类型和数据,并将其存储到用户模型中的 photo 字段中。`formidable`代码将进入`user.controller.js`中的`update`控制器中,如下所示。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -import formidable from 'formidable' -import fs from 'fs' -const update = (req, res, next) => { - let form = new formidable.IncomingForm() - form.keepExtensions = true - form.parse(req, (err, fields, files) => { - if (err) { - return res.status(400).json({ - error: "Photo could not be uploaded" - }) - } - let user = req.profile - user = _.extend(user, fields) - user.updated = Date.now() - if(files.photo){ - user.photo.data = fs.readFileSync(files.photo.path) - user.photo.contentType = files.photo.type - } - user.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - user.hashed_password = undefined - user.salt = undefined - res.json(user) - }) - }) -} -``` - -这将上传的文件作为数据存储在数据库中。接下来,我们将设置文件检索,以便能够在前端视图中访问和显示用户上传的照片。 - -# 检索个人资料照片 - -要检索存储在数据库中的文件并在视图中显示它,最简单的方法是设置一个路由,该路由将获取数据并将其作为图像文件返回给请求的客户端。 - -# 个人资料照片 URL - -我们将为每个用户设置一个到数据库中存储的照片的路由,并且还将添加另一个路由,如果给定用户没有上传个人资料照片,该路由将获取默认照片。 - -`mern-social/server/routes/user.routes.js`: - -```jsx -router.route('/api/users/photo/:userId') - .get(userCtrl.photo, userCtrl.defaultPhoto) -router.route('/api/users/defaultphoto') - .get(userCtrl.defaultPhoto) -``` - -我们将在`photo`控制器方法中查找照片,如果找到,则在照片路由的请求响应中发送,否则调用`next()`返回默认照片。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -const photo = (req, res, next) => { - if(req.profile.photo.data){ - res.set("Content-Type", req.profile.photo.contentType) - return res.send(req.profile.photo.data) - } - next() -} -``` - -从服务器的文件系统检索并发送默认照片。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -import profileImage from './../../client/iimg/profile-pic.png' -const defaultPhoto = (req, res) => { - return res.sendFile(process.cwd()+profileImage) -} -``` - -# 在视图中显示照片 - -通过设置照片 URL 路由来检索照片,我们只需在`img`元素的`src`属性中使用这些路由即可将照片加载到视图中。例如,在`Profile`组件中,我们从 state 获取用户 ID,并使用它来构建照片 URL。 - -`mern-social/client/user/Profile.js`: - -```jsx -const photoUrl = this.state.user._id - ? `/api/users/photo/${this.state.user._id}?${new Date().getTime()}` - : '/api/users/defaultphoto' -``` - -为了确保在编辑中更新照片后,`img`元素重新加载到`Profile`视图中,我们还向照片 URL 添加了一个时间值,以绕过浏览器的默认图像缓存行为 - -然后,我们可以将`photoUrl`设置为材质 UI`Avatar`组件,该组件在视图中渲染链接图像: - -```jsx - -``` - -MERN Social 中更新的用户配置文件现在可以显示用户上传的配置文件照片和`about`描述: - -![](img/8e568b24-3f3c-4d32-aabb-f2eaabbeca3a.png) - -# 在 MERN Social 中跟踪用户 - -在 MERN Social 中,用户将能够相互跟踪。每个用户都会有一个追随者列表和他们关注的人列表。用户还可以看到他们可以关注的用户列表;换句话说,MERN Social 中的用户还没有开始关注。 - -# 随波逐流 - -为了跟踪哪个用户在跟踪哪个其他用户,我们必须为每个用户维护两个列表。当一个用户跟随或取消跟随另一个用户时,我们将更新一个用户的`following`列表和另一个用户的`followers`列表。 - -# 更新用户模型 - -为了在数据库中存储`following`和`followers`列表,我们将使用两个用户引用数组更新用户模型。 - -`mern-social/server/models/user.model.js`: - -```jsx -following: [{type: mongoose.Schema.ObjectId, ref: 'User'}], -followers: [{type: mongoose.Schema.ObjectId, ref: 'User'}] -``` - -这些引用将指向集合中由给定用户跟随或跟随的用户。 - -# 更新 userByID 控制器方法 - -当从后端检索单个用户时,我们希望`user`对象包含`following`和`followers`数组中引用的用户的名称和 ID。要检索这些详细信息,我们需要更新`userByID`控制器方法来填充返回的用户对象。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -const userByID = (req, res, next, id) => { - User.findById(id) - .populate('following', '_id name') - .populate('followers', '_id name') - .exec((err, user) => { - if (err || !user) return res.status('400').json({ - error: "User not found" - }) - req.profile = user - next() - }) -} -``` - -我们使用 Mongoose`populate`方法指定查询返回的用户对象应该包含`following`和`followers`列表中引用的用户的名称和 ID。当我们使用 read API 调用获取用户时,这将为我们提供`followers`和`following`列表中用户引用的名称和 ID。 - -# 要遵循和展开的 API - -当一个用户跟随或从视图中取消跟随另一个用户时,数据库中两个用户的记录都将更新,以响应`follow`或`unfollow`请求。 - -我们将在`user.routes.js`中设置`follow`和`unfollow`路线,如下所示。 - -`mern-social/server/routes/user.routes.js`: - -```jsx -router.route('/api/users/follow') - .put(authCtrl.requireSignin, userCtrl.addFollowing, userCtrl.addFollower) -router.route('/api/users/unfollow') - .put(authCtrl.requireSignin, userCtrl.removeFollowing, userCtrl.removeFollower) -``` - -用户控制器中的`addFollowing`控制器方法将通过将后续用户的引用推送到数组中来更新当前用户的`'following'`数组。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -const addFollowing = (req, res, next) => { - User.findByIdAndUpdate(req.body.userId, {$push: {following: req.body.followId}}, (err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - next() - }) -} -``` - -成功更新后续数组后,执行`addFollower`方法将当前用户的引用添加到后续用户的`'followers'`数组中。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -const addFollower = (req, res) => { - User.findByIdAndUpdate(req.body.followId, {$push: {followers: req.body.userId}}, {new: true}) - .populate('following', '_id name') - .populate('followers', '_id name') - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - result.hashed_password = undefined - result.salt = undefined - res.json(result) - }) -} -``` - -对于 unfollowing,实现类似。`removeFollowing`和`removeFollower`控制器方法通过使用`$pull`而不是`$push`删除用户引用来更新相应的`'following'`和`'followers'`阵列。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -const removeFollowing = (req, res, next) => { - User.findByIdAndUpdate(req.body.userId, {$pull: {following: req.body.unfollowId}}, (err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - next() - }) -} -const removeFollower = (req, res) => { - User.findByIdAndUpdate(req.body.unfollowId, {$pull: {followers: req.body.userId}}, {new: true}) - .populate('following', '_id name') - .populate('followers', '_id name') - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - result.hashed_password = undefined - result.salt = undefined - res.json(result) - }) -} -``` - -# 在视图中访问跟随和取消跟随 API - -为了访问视图中的这些 API 调用,我们将使用`follow`和`unfollow`获取方法更新`api-user.js`。`follow`和`unfollow`方法将类似,使用当前用户的 ID 和凭证以及跟随或未跟随的用户 ID 调用各自的路由。`follow`方法将如下所示。 - -`mern-social/client/user/api-user.js`: - -```jsx -const follow = (params, credentials, followId) => { - return fetch('/api/users/follow/', { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify({userId:params.userId, followId: followId}) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -`unfollow`fetch 方法与此类似,它获取未跟随的用户 ID 并调用`unfollow`API - -`mern-social/client/user/api-user.js`: - -```jsx -const unfollow = (params, credentials, unfollowId) => { - return fetch('/api/users/unfollow/', { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify({userId:params.userId, unfollowId: unfollowId}) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 跟随和取消跟随按钮 - -允许用户`follow`或`unfollow`其他用户的按钮将根据当前用户是否已经跟随该用户有条件地出现: - -![](img/57d66ca2-131d-4717-8bae-2c74b263fbb7.png) - -# FollowProfileButton 组件 - -我们将为 follow 按钮创建一个名为`FollowProfileButton`的单独组件,该组件将添加到`Profile`组件中。此组件将显示`Follow`或`Unfollow`按钮,具体取决于当前用户是否已经是配置文件中用户的跟随者。`FollowProfileButton`部分如下所示。 - -`mern-social/client/user/FollowProfileButton.js`: - -```jsx -class FollowProfileButton extends Component { - followClick = () => { - this.props.onButtonClick(follow) - } - unfollowClick = () => { - this.props.onButtonClick(unfollow) - } - render() { - return (
- { this.props.following - ? () - : () - } -
) - } -} -FollowProfileButton.propTypes = { - following: PropTypes.bool.isRequired, - onButtonClick: PropTypes.func.isRequired -} -``` - -当`FollowProfileButton`被添加到配置文件中时,将确定`'following'`值,并将其作为道具从`Profile`组件发送到`FollowProfileButton`,以及将要调用的特定`follow`或`unfollow`获取 API 作为参数的点击处理程序: - -![](img/4854d95a-4432-44cf-9139-809cafb413dd.png) - -# 更新配置文件组件 - -在`Profile`视图中,只有当用户查看其他用户的配置文件时才会显示`FollowProfileButton`,所以我们需要修改查看配置文件时显示`Edit`和`Delete`按钮的条件,如下所示: - -```jsx -{auth.isAuthenticated().user && auth.isAuthenticated().user._id == this.state.user._id - ? (edit and delete buttons) - : (follow button) -} -``` - -在`Profile`组件中,在`componentDidMount`上成功抓取用户数据后,我们会检查登录用户是否已经跟随配置文件中的用户,并将`following`值设置为状态。 - -`mern-social/client/user/Profile.js`: - -```jsx -let following = this.checkFollow(data) -this.setState({user: data, following: following}) -``` - -为了确定在`following`中设置的值,`checkFollow`方法会检查被抓取用户的追随者列表中是否存在登录用户,如果找到则返回`match`,否则如果没有找到匹配则返回`undefined`。 - -`mern-social/client/user/Profile.js`: - -```jsx -checkFollow = (user) => { - const jwt = auth.isAuthenticated() - const match = user.followers.find((follower)=> { - return follower._id == jwt.user._id - }) - return match -} -``` - -`Profile`组件还将定义`FollowProfileButton`的点击处理程序,因此`Profile`的状态可以在后续或取消后续操作完成时更新。 - -`mern-social/client/user/Profile.js`: - -```jsx -clickFollowButton = (callApi) => { - const jwt = auth.isAuthenticated() - callApi({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.state.user._id).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({user: data, following: !this.state.following}) - } - }) -} -``` - -单击处理程序定义将 fetch API 调用作为参数,并在将其添加到`Profile`视图时作为道具与`following`值一起传递给`FollowProfileButton`。 - -`mern-social/client/user/Profile.js`: - -```jsx - -``` - -# 列出追随者和追随者 - -在每个用户的个人资料中,我们将添加他们的追随者和他们关注的人的列表: - -![](img/5efeb7d7-ccdc-418b-942f-790be8a838b3.png) - -`following`和`followers`列表中引用的用户的详细信息已经在加载概要文件时使用`read`API 获取的用户对象中。为了呈现这些单独的关注者和关注者列表,我们将创建一个名为`FollowGrid`的新组件 - -# 跟随网格组件 - -`FollowGrid`组件将用户列表作为道具,显示用户的头像及其姓名,并链接到每个用户的个人资料。我们可以根据需要将该组件添加到`Profile`视图中以显示`followings`或`followers`。 - -`mern-social/client/user/FollowGrid.js`: - -```jsx -class FollowGrid extends Component { - render() { - const {classes} = this.props - return (
- - {this.props.people.map((person, i) => { - return - - - {person.name} - - - - })} - -
) - } -} - -FollowGrid.propTypes = { - classes: PropTypes.object.isRequired, - people: PropTypes.array.isRequired -} -``` - -要将`FollowGrid`组件添加到`Profile`视图中,我们可以根据需要将其放置在视图中,并将`followers`或`followings`列表作为`people`道具传递: - -```jsx - - -``` - -如前所示,在 MERN Social 中,我们选择在`Profile`组件的选项卡中显示`FollowGrid`组件。我们使用材质 UI 选项卡组件创建了一个单独的`ProfileTabs`组件,并将其添加到`Profile`组件中。此`ProfileTabs`组件包含两个`FollowGrid`组件,其中包含以下和关注者列表,以及一个`PostList`组件,该组件显示用户发布的帖子。这将在本章后面讨论。 - -# 寻找要跟随的人 - -“关注谁”功能将向登录用户显示 MERN Social 中当前未关注的人的列表,并提供关注他们或查看其个人资料的选项: - -![](img/efd564df-5075-4d35-abfe-a7eff7add251.png) - -# 正在获取未跟踪的用户 - -我们将在服务器上实现一个新的 API,以查询数据库并获取当前用户未跟踪的用户列表。 - -`mern-social/server/routes/user.routes.js`: - -```jsx -router.route('/api/users/findpeople/:userId') - .get(authCtrl.requireSignin, userCtrl.findPeople) -``` - -在`findPeople`控制器方法中,我们将查询数据库中的用户集合,找到不在当前用户`following`列表中的用户。 - -`mern-social/server/controllers/user.controller.js`: - -```jsx -const findPeople = (req, res) => { - let following = req.profile.following - following.push(req.profile._id) - User.find({ _id: { $nin : following } }, (err, users) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(users) - }).select('name') -} -``` - -要在前端使用此用户列表,我们将更新`api-user.js`以添加此 find people API 的获取。 - -`mern-social/client/user/api-user.js`: - -```jsx -const findPeople = (params, credentials) => { - return fetch('/api/users/findpeople/' + params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# FindPeople 组件 - -为了显示*跟随者*功能,我们将创建一个名为`FindPeople`的组件,它可以添加到任何视图中,也可以自己渲染。在这个组件中,我们将首先获取用户,然后调用`componentDidMount`中的`findPeople`方法。 - -`mern-social/client/user/FindPeople.js`: - -```jsx -componentDidMount = () => { - const jwt = auth.isAuthenticated() - findPeople({ - userId: jwt.user._id - }, { - t: jwt.token - }).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({users: data}) - } - }) -} -``` - -获取的用户列表将被迭代并呈现在材质 UI`List`组件中,每个列表项包含用户的化身、名称、到配置文件页面的链接和`Follow`按钮。 - -`mern-social/client/user/FindPeople.js`: - -```jsx -{this.state.users.map((item, i) => { - return - - - - - - - - - - - - - - - - }) - } - -``` - -点击`Follow`按钮将调用 follow API,并通过拼接出新跟踪的用户来更新要跟踪的用户列表。 - -`mern-social/client/user/FindPeople.js`: - -```jsx -clickFollow = (user, index) => { - const jwt = auth.isAuthenticated() - follow({ - userId: jwt.user._id - }, { - t: jwt.token - }, user._id).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - let toFollow = this.state.users - toFollow.splice(index, 1) - this.setState({users: toFollow, open: true, followMessage: - `Following ${user.name}!`}) - } - }) -} -``` - -我们还将添加一个 Material UI`Snackbar`组件,当用户被成功跟踪时,该组件将临时打开,告诉用户他们开始跟踪这个新用户。 - -`mern-social/client/user/FindPeople.js`: - -```jsx -{this.state.followMessage}} -/> -``` - -`Snackbar`将在页面右下角显示消息,并在设置的持续时间后自动隐藏: - -![](img/104ce6be-98c8-49ef-a7dd-0864653e8fbd.png) - -MERN 社交用户现在可以互相关注,查看每个用户的关注者和追随者列表,还可以查看他们可以关注的人列表。在 MERN Social 中跟踪另一个用户的主要目的是跟踪他们的社交帖子,因此接下来我们将研究帖子功能的实现。 - -# 帖子 - -MERN Social 中的发布功能允许用户在 MERN Social 应用平台上共享内容,并通过评论或喜欢帖子的方式在内容上相互交流: - -![](img/fcf7142d-c1e5-42a2-9201-6718b4ac6735.png) - -# Post 的 Mongoose 模式模型 - -为了存储每个帖子,我们将首先在`server/models/post.model.js`中定义 Mongoose 模式。帖子模式将存储帖子的文本内容、照片、对发布用户的引用、创建时间、用户对帖子的喜好以及用户对帖子的评论: - -* **帖子文本**:`text`将是用户在新建帖子时从以下视图提供的必填字段: - -```jsx -text: { - type: String, - required: 'Name is required' -} -``` - -* **帖子照片**:`photo`将在帖子创建过程中从用户本地文件上传,并存储在 MongoDB 中,类似于用户档案照片上传功能。每个帖子的照片都是可选的: - -```jsx -photo: { - data: Buffer, - contentType: String -} -``` - -* **发帖人**:创建发帖需要用户先登录,因此我们可以在`postedBy`字段中存储对发帖用户的引用: - -```jsx -postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'} -``` - -* **创建时间**:在数据库中创建后期时自动生成`created`时间: - -```jsx -created: { type: Date, default: Date.now } -``` - -* **喜欢**:对喜欢特定帖子的用户的引用将存储在`likes`数组中: - -```jsx -likes: [{type: mongoose.Schema.ObjectId, ref: 'User'}] -``` - -* **评论**:帖子上的每条评论都将包含文本内容、创建时间以及对发布评论的用户的引用。每个帖子将有一个`comments`数组: - -```jsx -comments: [{ - text: String, - created: { type: Date, default: Date.now }, - postedBy: { type: mongoose.Schema.ObjectId, ref: 'User'} - }] -``` - -这个模式定义将使我们能够在 MERN Social 中实现所有与 post 相关的特性。 - -# 新闻源组件 - -在深入研究 MERN Social 中发布功能的实现之前,我们将查看 Newsfeed 视图的组成,以展示如何设计共享状态的嵌套 UI 组件的基本示例。`Newsfeed`组件将包含两个主要子组件—一个新的帖子表单和来自以下用户的帖子列表: - -![](img/478879e6-50a8-4123-9269-1c037eee5f2e.jpg) - -`Newsfeed`组件的基本结构如下,包括`NewPost`组件和`PostList`组件。 - -`mern-social/client/post/Newsfeed.js`: - -```jsx - - Newsfeed - - - - - -``` - -作为父组件,`Newsfeed`将控制子组件中呈现的帖子数据的状态。它将提供一种在子组件内修改 post 数据时跨组件更新 post 状态的方法,例如在`NewPost`组件中添加新 post,或从`PostList`组件中删除 post。 - -具体来说,`Newsfeed`中的`loadPosts`函数最初会调用服务器,从当前登录用户关注的人那里获取帖子列表,并将其设置为`PostList`组件中呈现的状态。`Newsfeed`组件为`NewPost`和`PostList`提供`addPost`和`removePost`功能,当创建新帖子或删除现有帖子时,将使用该功能更新处于`Newsfeed`状态的帖子列表,并最终反映在`PostList`中 - -`Newsfeed`组件中定义的`addPost`函数将获取`NewPost`组件中创建的新帖子,并将其添加到状态中的帖子中。 - -`mern-social/client/post/Newsfeed.js`: - -```jsx -addPost = (post) => { - const updatedPosts = this.state.posts - updatedPosts.unshift(post) - this.setState({posts: updatedPosts}) -} -``` - -`Newsfeed`组件中定义的`removePost`功能将从`PostList`中的`Post`组件中获取已删除的帖子,并将其从状态中的帖子中移除。 - -`mern-social/client/post/Newsfeed.js`: - -```jsx -removePost = (post) => { - const updatedPosts = this.state.posts - const index = updatedPosts.indexOf(post) - updatedPosts.splice(index, 1) - this.setState({posts: updatedPosts}) -} -``` - -由于帖子以这种方式更新为`Newsfeed`的状态,`PostList`将向查看者呈现更改后的帖子列表。这种将状态更新从父组件转发到子组件并返回的机制将应用于其他功能,例如帖子中的注释更新,以及为`Profile`组件中的单个用户呈现`PostList`时。 - -# 列名职位 - -在 MERN Social 中,我们将在`Newsfeed`和每个用户的个人资料中列出帖子。我们将创建一个通用的`PostList`组件,它将呈现提供给它的任何帖子列表,我们可以在`Newsfeed`和`Profile`组件中使用它。 - -`mern-social/client/post/PostList.js`: - -```jsx -class PostList extends Component { - render() { - return ( -
- {this.props.posts.map((item, i) => { - return - }) - } -
- ) - } -} -PostList.propTypes = { - posts: PropTypes.array.isRequired, - removeUpdate: PropTypes.func.isRequired -} -``` - -`PostList`组件将遍历作为道具从`Newsfeed`或`Profile`传递给它的帖子列表,并将每个帖子的数据传递给`Post`组件,该组件将呈现帖子的细节。`PostList`还将把作为道具从父组件发送到`Post`组件的`removeUpdate`功能传递给`Post`组件,因此删除单个帖子时可以更新状态 - -# 新闻提要中的列表 - -我们将在服务器上设置一个 API,用于查询帖子集合,并返回指定用户关注的人的帖子。所以这些帖子可能会显示在`Newsfeed`的`PostList`中。 - -# 帖子的新闻提要 API - -此特定于新闻源的 API 将通过`server/routes/post.routes.js`中定义的以下路径接收请求: - -```jsx -router.route('/api/posts/feed/:userId') - .get(authCtrl.requireSignin, postCtrl.listNewsFeed) -``` - -我们在这个路由中使用`:userID`参数来指定当前登录的用户,我们将使用`user.controller`中的`userByID`控制器方法来获取用户详细信息,就像我们之前做的那样,并将它们附加到`listNewsFeed`post 控制器方法中访问的请求对象中。因此,在`mern-social/server/routes/post.routes.js`中也添加以下内容: - -```jsx -router.param('userId', userCtrl.userByID) -``` - -`post.routes.js`文件将非常类似于`user.routes.js`文件,要在 Express 应用中加载这些新路由,我们需要在`express.js`中装载 post 路由,就像我们对 auth 和 user 路由所做的那样。 - -`mern-social/server/express.js`: - -```jsx -app.use('/', postRoutes) -``` - -`post.controller.js`中的`listNewsFeed`控制器方法将查询数据库中的帖子集合,以获得匹配的帖子。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const listNewsFeed = (req, res) => { - let following = req.profile.following - following.push(req.profile._id) - Post.find({postedBy: { $in : req.profile.following } }) - .populate('comments', 'text created') - .populate('comments.postedBy', '_id name') - .populate('postedBy', '_id name') - .sort('-created') - .exec((err, posts) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(posts) - }) -} -``` - -在对帖子集合的查询中,我们找到了所有具有`postedBy`用户引用的帖子,这些用户引用与当前用户的以下内容和当前用户匹配。 - -# 在视图中获取新闻提要帖子 - -为了在前端使用此 API,我们将在`client/post/api-post.js`中添加一个获取方法: - -```jsx -const listNewsFeed = (params, credentials) => { - return fetch('/api/posts/feed/'+ params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -这是将加载在`PostList`中呈现的帖子的 fetch 方法,该帖子作为子组件添加到`Newsfeed`组件中。因此需要在`Newsfeed`组件中的`loadPosts`方法中调用此提取。 - -`mern-social/client/post/Newsfeed.js`: - -```jsx - loadPosts = () => { - const jwt = auth.isAuthenticated() - listNewsFeed({ - userId: jwt.user._id - }, { - t: jwt.token - }).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({posts: data}) - } - }) - } -``` - -将在`Newsfeed`组件的`componentDidMount`中调用`loadPosts`方法,以初始加载状态,并在`PostList`组件中呈现帖子: - -![](img/c75625f8-f71b-4493-b7f1-421d636be764.png) - -# 在配置文件中按用户列出 - -获取特定用户创建的帖子列表并在`Profile`中显示的实现类似于上一节的讨论。我们将在服务器上设置一个 API,用于查询帖子集合,并将特定用户的帖子返回到`Profile`视图。 - -# 用户发布帖子的 API - -`mern-social/server/routes/post.routes.js`中增加接收特定用户回帖查询的路由: - -```jsx -router.route('/api/posts/by/:userId') - .get(authCtrl.requireSignin, postCtrl.listByUser) -``` - -`post.controller.js`中的`listByUser`控制器方法将查询帖子集合,以查找在`postedBy`字段中与路由中`userId`参数中指定的用户具有匹配引用的帖子。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const listByUser = (req, res) => { - Post.find({postedBy: req.profile._id}) - .populate('comments', 'text created') - .populate('comments.postedBy', '_id name') - .populate('postedBy', '_id name') - .sort('-created') - .exec((err, posts) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(posts) - }) -} -``` - -# 获取视图中的用户帖子 - -为了在前端使用此 API,我们将在`mern-social/client/post/api-post.js`中添加一个获取方法: - -```jsx -const listByUser = (params, credentials) => { - return fetch('/api/posts/by/'+ params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -此`fetch`方法将加载添加到`Profile`视图的`PostList`所需的帖子。我们将更新`Profile`组件以定义一个调用`listByUser`获取方法的`loadPosts`方法。 - -`mern-social/client/user/Profile.js`: - -```jsx -loadPosts = (user) => { - const jwt = auth.isAuthenticated() - listByUser({ - userId: user - }, { - t: jwt.token - }).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({posts: data}) - } - }) -} -``` - -在`Profile`组件中,在`init()`函数中从服务器获取用户详细信息后,将使用加载配置文件的用户的用户 ID 调用`loadPosts`方法。为特定用户加载的帖子设置为状态,并在添加到`Profile`组件的`PostList`组件中呈现。`Profile`组件还提供了`removePost`功能,类似于`Newsfeed`组件,作为`PostList`组件的支柱,因此如果删除帖子,可以更新帖子列表: - -![](img/2a98e73d-8d6a-4305-a3ec-d5060cb8db04.png) - -# 创建新帖子 - -“创建新帖子”功能将允许登录用户发布消息,并可以通过从本地文件上载图像来选择性地向帖子添加图像。 - -# 创建 PostAPI - -在服务器上,我们将定义一个 API 在数据库中创建 post,首先在`mern-social/server/routes/post.routes.js`中的`/api/posts/new/:userId`处声明一条接受 post 请求的路由: - -```jsx -router.route('/api/posts/new/:userId') - .post(authCtrl.requireSignin, postCtrl.create) -``` - -`post.controller.js`中的`create`方法将使用`formidable`模块访问字段和图像文件(如果有),就像我们对用户配置文件照片更新所做的那样。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const create = (req, res, next) => { - let form = new formidable.IncomingForm() - form.keepExtensions = true - form.parse(req, (err, fields, files) => { - if (err) { - return res.status(400).json({ - error: "Image could not be uploaded" - }) - } - let post = new Post(fields) - post.postedBy= req.profile - if(files.photo){ - post.photo.data = fs.readFileSync(files.photo.path) - post.photo.contentType = files.photo.type - } - post.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) - }) -} -``` - -# 检索帖子的照片 - -为了检索上传的照片,我们还将设置一个`photo`路由 URL,返回带有特定帖子的照片。 - -`mern-social/server/routes/post.routes.js`: - -```jsx -router.route('/api/posts/photo/:postId').get(postCtrl.photo) -``` - -`photo`控制器将返回存储在 MongoDB 中的`photo`数据作为图像文件 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const photo = (req, res, next) => { - res.set("Content-Type", req.post.photo.contentType) - return res.send(req.post.photo.data) -} -``` - -由于 photo route 使用了`:postID`参数,我们将设置一个`postByID`控制器方法,在返回 photo 请求之前,通过其 ID 获取特定帖子。我们将把 param 调用添加到`post.routes.js`。 - -`mern-social/server/routes/post.routes.js`: - -```jsx - router.param('postId', postCtrl.postByID) -``` - -`postByID`将类似于`userByID`方法,它将从数据库检索到的 post 附加到请求对象,通过`next`方法访问。此实现中附带的 post 数据还将包含`postedBy`用户引用的 ID 和名称。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const postByID = (req, res, next, id) => { - Post.findById(id).populate('postedBy', '_id name').exec((err, post) => { - if (err || !post) - return res.status('400').json({ - error: "Post not found" - }) - req.post = post - next() - }) -} -``` - -# 在视图中获取 CreatePostAPI - -我们将更新`api-post.js`以添加`create`方法来调用`fetch`创建 API。 - -`mern-social/client/post/api-post.js`: - -```jsx -const create = (params, credentials, post) => { - return fetch('/api/posts/new/'+ params.userId, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: post - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -此方法与用户`edit`fetch 一样,将使用一个`FormData`对象发送一个多部分表单提交,该对象可以包含文本字段和图像文件。 - -# NewPost 组件 - -`Newsfeed`组件中添加的`NewPost`组件将允许用户编写包含文本消息和可选图像的新帖子: - -![](img/6380d9cd-a320-45c7-817d-14bd5eb04018.png) - -`NewPost`组件将是一个标准表单,具有`EditProfile`中实现的物料 UI`TextField`和文件上传按钮,该按钮获取值并将其设置在`FormData`对象中,以便在提交后调用`create`获取方法时传递。 - -`mern-social/client/post/NewPost.js`: - -```jsx -clickPost = () => { - const jwt = auth.isAuthenticated() - create({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.postData).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({text:'', photo: ''}) - this.props.addUpdate(data) - } - }) -} -``` - -`NewPost`组件作为子组件添加到`Newsfeed`中,并作为道具给出`addUpdate`方法。成功创建帖子后,表单视图被清空并执行`addUpdate`,因此`Newsfeed`中的帖子列表将用新帖子更新。 - -# 后组件 - -每个帖子中的帖子细节将在`Post`组件中呈现,该组件将从`PostList`组件接收作为道具的帖子数据,以及删除帖子时要应用的`onRemove`道具。 - -# 布局 - -`Post`组件布局将有一个标题,显示海报的详细信息、帖子的内容、一个带有喜欢和评论计数的操作栏,以及*评论*部分: - -![](img/069a0ac2-e7cf-4192-99b0-15f8656ddd9d.png) - -# 标题 - -标题将包含诸如姓名、头像、发布用户的个人资料链接以及发布日期等信息。 - -`mern-social/client/post/Post.js`: - -```jsx -} - action={this.props.post.postedBy._id === - auth.isAuthenticated().user._id && - - - - } - title={ - {this.props.post.postedBy.name} - } - subheader={(new Date(this.props.post.created)).toDateString()} - className={classes.cardHeader} -/> -``` - -如果登录用户正在查看自己的帖子,标题也会有条件地显示一个`delete`按钮。 - -# 所容纳之物 - -内容部分将显示文章的文本和图像(如果文章包含照片)。 - -`mern-social/client/post/Post.js`: - -```jsx - - - {this.props.post.text} - - {this.props.post.photo && - (
- -
) - } -
-``` - -# 行动 - -“操作”部分将包含一个交互式`"like"`选项,其中包含帖子上的喜欢总数,以及一个评论图标,其中包含帖子上的评论总数。 - -`mern-social/client/post/Post.js`: - -```jsx - - { this.state.like - ? - - - : - - - } {this.state.likes} - - - {this.state.comments.length} - -``` - -# 评论 - -comments 部分将包含`Comments`组件中所有与评论相关的元素,并将获得`props`数据,如`postId`和`comments`数据,以及`state`更新方法,在`Comments`组件中添加或删除评论时可以调用该更新方法。 - -`mern-social/client/post/Post.js`: - -```jsx - -``` - -# 删除帖子 - -`delete`按钮仅在登录用户和`postedBy`用户对于呈现的特定帖子相同时可见。对于要从数据库中删除的帖子,我们必须设置一个 delete post API,该 API 在前端也将有一个 fetch 方法,以便在单击`delete`时应用。 - -`mern-social/server/routes/post.routes.js`: - -```jsx -router.route('/api/posts/:postId') - .delete(authCtrl.requireSignin, - postCtrl.isPoster, - postCtrl.remove) -``` - -删除路由在调用 post 上的`remove`之前会检查授权,确保认证用户和`postedBy`用户是相同的用户。`isPoster`方法在执行`next`方法之前会检查登录用户是否是 post 的原始创建者。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const isPoster = (req, res, next) => { - let isPoster = req.post && req.auth && - req.post.postedBy._id == req.auth._id - if(!isPoster){ - return res.status('403').json({ - error: "User is not authorized" - }) - } - next() -} -``` - -使用`remove`控制器方法的 delete API 的其余实现和前端的 fetch 方法与其他 API 实现相同。这里的重要区别在于 delete post 特性,当 delete 成功时,调用`Post`组件中的`onRemove`更新方法。`onRemove`方法作为道具从`Newsfeed`或`Profile`发送,以在删除成功时更新状态中的帖子列表。 - -当点击帖子上的`delete`按钮时,将调用`Post`组件中定义的以下`deletePost`方法。 - -`mern-social/client/post/Post.js`: - -```jsx -deletePost = () => { - const jwt = auth.isAuthenticated() - remove({ - postId: this.props.post._id - }, { - t: jwt.token - }).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.props.onRemove(this.props.post) - } - }) -} -``` - -此方法对 delete post API 进行 fetch 调用,并在成功时通过执行从父组件作为道具接收的`onRemove`方法来更新处于该状态的帖子列表。 - -# 喜欢 - -`Post`组件的操作栏部分中的 like 选项将允许用户喜欢或不喜欢某篇文章,并显示该文章的喜欢总数。要记录一个 like,我们必须设置可以在视图中调用的 like 和 inspect API。 - -# 像 API - -like API 将是更新`Post`文档中`likes`数组的 PUT 请求。该请求将在路由`api/posts/like`处接收。 - -`mern-social/server/routes/post.routes.js`: - -```jsx - router.route('/api/posts/like') - .put(authCtrl.requireSignin, postCtrl.like) -``` - -在`like`控制器方法中,请求体中接收到的 post ID 将被用于查找 post 文档,并通过将当前用户的 ID 推送到`likes`数组进行更新。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const like = (req, res) => { - Post.findByIdAndUpdate(req.body.postId, - {$push: {likes: req.body.userId}}, {new: true}) - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) -} -``` - -要使用此 API,将在`api-post.js`中添加一个名为`like`的获取方法,当用户单击`like`按钮时将使用该方法。 - -`mern-social/client/post/api-post.js`: - -```jsx -const like = (params, credentials, postId) => { - return fetch('/api/posts/like/', { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify({userId:params.userId, postId: postId}) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 与 API 不同 - -`unlike`API 的实现方式与同类 API 类似,在`mern-social/server/routes/post.routes.js`有自己的路由: - -```jsx - router.route('/api/posts/unlike') - .put(authCtrl.requireSignin, postCtrl.unlike) -``` - -控制器中的`unlike`方法将通过其 ID 找到帖子,并通过使用`$pull`而不是`$push`删除当前用户的 ID 来更新`likes`数组。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const unlike = (req, res) => { - Post.findByIdAndUpdate(req.body.postId, {$pull: {likes: req.body.userId}}, {new: true}) - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) -} -``` - -与之不同的 API 还将具有与`api-post.js`中的`like`方法类似的相应获取方法。 - -# 检查是否喜欢并计算喜欢 - -当呈现`Post`组件时,我们需要检查当前登录的用户是否喜欢该帖子,以便显示相应的`like`选项。 - -`mern-social/client/post/Post.js`: - -```jsx -checkLike = (likes) => { - const jwt = auth.isAuthenticated() - let match = likes.indexOf(jwt.user._id) !== -1 - return match -} -``` - -在`Post`组件的`componentDidMount`和`componentWillReceiveProps`期间可以调用`checkLike`函数,在检查当前用户是否在 post 的`likes`数组中被引用后,为 post 设置`like`状态: - -![](img/93a0e91b-5080-41d1-8d30-777ca5e05b08.png) - -在使用`checkLike`方法的状态下设置的`like`值可用于渲染心脏轮廓按钮或完整心脏按钮。如果用户不喜欢该帖子,则会显示一个心形轮廓按钮,单击该按钮将调用`like`API,显示完整的心形按钮,并增加`likes`计数。全心按钮将表示当前用户已经喜欢此帖子,单击此按钮将调用`unlike`API,呈现心脏轮廓按钮,并减少`likes`计数。 - -通过将`likes`值设置为`this.props.post.likes.length`状态,在`Post`组件安装和接收道具时,也会初始设置`likes`计数。 - -`mern-social/client/post/Post.js`: - -```jsx -componentDidMount = () => { - this.setState({like:this.checkLike(this.props.post.likes), - likes: this.props.post.likes.length, - comments: this.props.post.comments}) -} -componentWillReceiveProps = (props) => { - this.setState({like:this.checkLike(props.post.likes), - likes: props.post.likes.length, - comments: props.post.comments}) -} -``` - -当发生相似或不相似的操作时,`likes`相关值将再次更新,更新后的 post 数据将从 API 调用返回。 - -# 像咔哒声一样处理 - -为了处理对`like`和`unlike`按钮的点击,我们将设置一个`like`方法,该方法将根据是否是相似操作调用相应的获取方法,并更新帖子的`like`和`likes`计数状态。 - -`mern-social/client/post/Post.js`: - -```jsx -like = () => { - let callApi = this.state.like ? unlike : like - const jwt = auth.isAuthenticated() - callApi({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.props.post._id).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({like: !this.state.like, likes: - data.likes.length}) - } - }) - } -``` - -# 评论 - -每个帖子中的评论部分将允许登录用户添加评论、查看评论列表以及删除自己的评论。对注释列表的任何更改(如新添加或删除)都将更新注释以及`Post`组件的操作栏部分中的注释计数: - -![](img/84e0c3cf-868d-4c90-ae59-2579d1cd8956.png) - -# 添加评论 - -当用户添加注释时,数据库中的 post 文档将使用新注释进行更新。 - -# 注释 API - -为了实现 addcomment API,我们将设置如下的`PUT`路径来更新帖子。 - -`mern-social/server/routes/post.routes.js`: - -```jsx -router.route('/api/posts/comment') - .put(authCtrl.requireSignin, postCtrl.comment) -``` - -`comment`控制器方法将通过其 ID 找到要更新的相关帖子,并将请求正文中接收到的评论对象推送到帖子的`comments`数组中。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const comment = (req, res) => { - let comment = req.body.comment - comment.postedBy = req.body.userId - Post.findByIdAndUpdate(req.body.postId, - {$push: {comments: comment}}, {new: true}) - .populate('comments.postedBy', '_id name') - .populate('postedBy', '_id name') - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) -} -``` - -在响应中,更新后的 post 对象将被发回,其中包含在 post 和评论中填充的`postedBy`用户的详细信息。 - -为了在视图中使用此 API,我们将在`api-post.js`中设置一个获取方法,该方法从视图中获取当前用户 ID、post ID 和`comment`对象,并与添加注释请求一起发送。 - -`mern-social/client/post/api-post.js`: - -```jsx -const comment = (params, credentials, postId, comment) => { - return fetch('/api/posts/comment/', { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify({userId:params.userId, postId: postId, - comment: comment}) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 在视图中写东西 - -`Comments`组件中的*添加注释*部分将允许登录用户键入注释文本: - -![](img/8a7002b9-893f-45ee-a3f0-abea084f1896.png) - -它将包含一个带有用户照片的化身和一个文本字段,当用户按下*回车*键时,该字段将添加注释。 - -`mern-social/client/post/Comments.js`: - -```jsx -} - title={} - className={classes.cardHeader} -/> -``` - -当值改变时,文本将以状态存储,在`onKeyDown`事件中,如果按下*Enter*键,`addComment`方法将调用`comment`fetch 方法。 - -`mern-social/client/post/Comments.js`: - -```jsx -addComment = (event) => { - if(event.keyCode == 13 && event.target.value){ - event.preventDefault() - const jwt = auth.isAuthenticated() - comment({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.props.postId, {text: this.state.text}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({text: ''}) - this.props.updateComments(data.comments) - } - }) - } -} -``` - -`Comments`组件从`Post`组件接收`updateComments`方法(在上一节中讨论)作为道具。这将在添加新注释时执行,以便更新 Post 视图中的注释和注释计数。 - -# 列表注释 - -`Comments`组件从`Post`组件接收特定帖子的评论列表作为道具,然后迭代各个评论以呈现评论人的详细信息和评论内容。 - -`mern-social/client/post/Comments.js`: - -```jsx -{this.props.comments.map((item, i) => { - return - } - title={commentBody(item)} - className={classes.cardHeader} - key={i}/> - }) -} -``` - -`commentBody`呈现内容,包括链接到其个人资料的评论人的姓名、评论文本和评论创建日期。 - -`mern-social/client/post/Comments.js`: - -```jsx -const commentBody = item => { - return ( -

- {item.postedBy.name} -
- {item.text} - - {(new Date(item.created)).toDateString()} | - {auth.isAuthenticated().user._id === item.postedBy._id && - delete } - -

- ) -} -``` - -如果注释的`postedBy`引用与当前登录的用户匹配,`commentBody`还将为注释提供删除选项。 - -# 删除评论 - -点击评论中的删除按钮将通过从`comments`数组中删除评论来更新数据库中的帖子: - -![](img/e20462d0-c78b-4d57-bb3a-52796e7f29f3.png) - -# 取消注释 API - -我们将在下面的 PUT 路径上实现一个`uncomment`API。 - -`mern-social/server/routes/post.routes.js`: - -```jsx -router.route('/api/posts/uncomment') - .put(authCtrl.requireSignin, postCtrl.uncomment) -``` - -`uncomment`控制器方法将通过 ID 找到相关帖子,然后从帖子中的`comments`数组中提取带有已删除评论 ID 的评论。 - -`mern-social/server/controllers/post.controller.js`: - -```jsx -const uncomment = (req, res) => { - let comment = req.body.comment - Post.findByIdAndUpdate(req.body.postId, {$pull: {comments: {_id: comment._id}}}, {new: true}) - .populate('comments.postedBy', '_id name') - .populate('postedBy', '_id name') - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) -} -``` - -更新后的帖子将在回复中返回,就像在 commentapi 中一样。 - -为了在视图中使用此 API,我们还将在`api-post.js`中设置一个 fetch 方法,类似于 add`comment`fetch 方法,该方法使用当前用户 ID、post ID 和删除的`comment`对象与`uncomment`请求一起发送。 - -# 从视图中删除注释 - -当注释者点击注释的删除按钮时,`Comments`组件将调用`deleteComment`方法获取`uncomment`API,并在注释成功从服务器上删除时更新注释和注释计数。 - -`mern-social/client/post/Comments.js`: - -```jsx -deleteComment = comment => event => { - const jwt = auth.isAuthenticated() - uncomment({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.props.postId, comment).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.props.updateComments(data.comments) - } - }) - } -``` - -# 注释计数更新 - -`updateComments`方法在`Post`组件中定义,并作为道具传递给`Comments`组件,该方法将在添加或删除注释时更新`comments`和注释计数。 - -`mern-social/client/post/Post.js`: - -```jsx -updateComments = (comments) => { - this.setState({comments: comments}) -} -``` - -此方法将更新的注释列表作为参数,并更新保存视图中呈现的注释列表的状态。当`Post`组件挂载时,设置 Post 组件中注释的初始状态,并将 Post 数据作为道具接收。此处设置的注释作为道具发送到`Comments`组件,还用于在帖子布局的操作栏中呈现 likes 操作旁边的注释计数,如下所示。 - -`mern-social/client/post/Post.js`: - -```jsx - - - {this.state.comments.length} -``` - -`Post`组件中的注释计数与`Comments`组件中呈现和更新的注释之间的这种关系,再次简单演示了如何在 React 中的嵌套组件之间共享不断变化的数据,以创建动态的交互式用户界面 - -MERN 社交应用包含了我们之前为该应用定义的一组功能。用户可以用照片和描述更新他们的个人资料,在应用上相互跟踪,用照片和文本创建帖子,以及对帖子进行评论。这里显示的实现可以进一步调整和扩展,以添加更多特性,利用所揭示的使用 MERN 堆栈的机制。 - -# 总结 - -本章中开发的 MERN 社交应用演示了如何将 MERN 堆栈技术结合起来,构建一个具有社交媒体功能的功能齐全的 web 应用。 - -我们首先更新了 skeleton 应用中的用户功能,允许在 MERN Social 上拥有帐户的任何人添加关于自己的描述,并从本地文件上传个人资料图片。在上传配置文件图片的实现中,我们探索了如何从客户端上传多部分表单数据,然后在服务器上接收数据,将文件数据直接存储在 MongoDB 数据库中,然后能够检索回来进行查看。 - -接下来,我们进一步更新了用户功能,以允许用户在 MERN 社交平台上相互关注。在用户模型中,我们添加了维护用户引用数组的功能,以表示每个用户的关注者和关注者列表。为了扩展这一功能,我们在视图中加入了 follow 和 unfollow 选项,并显示了关注者列表、关注者列表,甚至还显示了尚未关注的用户列表。 - -然后,我们添加了允许用户发布内容的功能,并通过喜欢或评论文章来与内容进行交互。在后端,我们建立了 Post 模型和相应的 api,能够存储可能包含或不包含图像的 Post 内容,并维护任何用户对 Post 的喜欢和评论记录。 - -最后,在实现发布、喜欢和评论功能的视图时,我们探索了如何使用组件组合并在组件之间共享不断变化的状态值,以创建复杂的交互式视图。   - -在下一章中,我们将进一步扩展 MERN 堆栈中的这些功能,并在通过扩展 MERN 骨架应用开发在线市场应用时释放新的可能性。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/06.md b/docs/full-stk-react-proj/06.md deleted file mode 100644 index 96283e0..0000000 --- a/docs/full-stk-react-proj/06.md +++ /dev/null @@ -1,1737 +0,0 @@ -# 六、通过在线市场练习新的 MERN 技能 - -随着越来越多的企业继续转向网络,在在线市场环境下进行买卖的能力已成为许多网络平台的核心要求。在本章和下一章中,我们将利用 MERN stack 技术开发一个在线市场应用,该应用具有使用户能够买卖的功能。 - -在本章中,我们将通过扩展具有以下功能的 MERN 框架来开始构建在线市场: - -* 具有卖家帐户的用户 -* 店铺管理 -* 产品管理 -* 按名称和类别进行产品搜索 - -# 梅恩市场 - -MERN Marketplace 应用将允许用户成为卖家,他们可以管理多个店铺,并在每个店铺添加他们想要销售的产品。访问 MERN Marketplace 的用户将能够搜索和浏览他们想要购买的产品,并将产品添加到他们的购物车以下订单: - -![](img/3a5e153a-60bd-4cba-8f79-523426175c96.png) - -The code for the complete MERN Marketplace application is available on GitHub: [github.com/shamahoque/mern-marketplace](https://github.com/shamahoque/mern-marketplace). The implementations discussed in this chapter can be accessed in the seller-shops-products branch of the repository. You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -将通过扩展和修改 MERN skeleton 应用中的现有 React 组件来开发与卖家帐户、商店和产品相关的功能所需的视图。下图中的组件树显示了构成本章开发的 MERN Marketplace 前端的所有自定义 React 组件: - -![](img/80051e3f-cad6-4cc1-b7ff-457b701d9998.jpg) - -# 作为卖家的用户 - -任何在 MERN Marketplace 注册的用户都可以通过更新其个人资料选择成为卖家: - -![](img/59234305-80cf-4846-9bf4-def391fdfd9b.png) - -与普通用户不同,成为卖家将允许用户创建和管理自己的店铺,在那里他们可以管理产品: - -![](img/5c7b9488-dd1a-474d-977f-75f6a80fa956.png) - -要添加此卖家功能,我们需要更新用户模型、编辑纵断面图,并将“我的店铺”链接添加到仅卖家可见的菜单。 - -# 更新用户模型 - -用户模型需要一个卖家值,默认情况下,卖家值设置为`false`表示普通用户,也可以设置为`true`表示同样是卖家的用户。 - -`mern-marketplace/server/models/user.model.js`: - -```jsx -seller: { - type: Boolean, - default: false -} -``` - -The seller value must be sent to the client with the user details received on successful sign-in, so the view can be rendered accordingly to show information relevant to the seller. - -# 更新编辑纵断面图 - -登录用户将在编辑纵断面图中看到一个切换,以激活或停用卖方功能。我们将更新`EditProfile`组件,在`FormControlLabel`中添加`Material-UI``Switch`组件。 - -`mern-marketplace/client/user/EditProfile.js`: - -```jsx - - Seller Account - - } - label={this.state.seller? 'Active' : 'Inactive'} -/> -``` - -对开关的任何更改都将通过调用`handleCheck`方法设置为`seller`in 状态的值。 - -`mern-marketplace/client/user/EditProfile.js`: - -```jsx -handleCheck = (event, checked) => { - this.setState({'seller': checked}) -} -``` - -提交时,`seller`值将添加到更新中发送到服务器的详细信息中。 - -`mern-marketplace/client/user/EditProfile.js`: - -```jsx -clickSubmit = () => { - const jwt = auth.isAuthenticated() - const user = { - name: this.state.name || undefined, - email: this.state.email || undefined, - password: this.state.password || undefined, - seller: this.state.seller - } - update({ - userId: this.match.params.userId - }, { - t: jwt.token - }, user).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - auth.updateUser(data, ()=> { - this.setState({'userId':data._id,'redirectToProfile':true}) - }) - } - }) - } -``` - -更新成功后,`sessionStorage`中存储的用于身份验证的用户详细信息也应更新。调用`auth.updateUser`方法来进行`sessionStorage`更新。它与其他`auth-helper.js`方法一起定义,并将更新的用户数据和更新视图的回调函数作为参数传递。 - -`mern-marketplace/client/auth/auth-helper.js`: - -```jsx -updateUser(user, cb) { - if(typeof window !== "undefined"){ - if(sessionStorage.getItem('jwt')){ - let auth = JSON.parse(sessionStorage.getItem('jwt')) - auth.user = user - sessionStorage.setItem('jwt', JSON.stringify(auth)) - cb() - } - } -} -``` - -# 更新菜单 - -在导航栏中,为了有条件地显示指向*My Shops*的链接,该链接仅对同时也是卖家的已登录用户可见,我们将在之前的代码中更新`Menu`组件,如下所示,该代码仅在用户登录时呈现。 - -`mern-marketplace/client/core/Menu.js`: - -```jsx -{auth.isAuthenticated().user.seller && - ( - - ) -} -``` - -# 市场上的商店 - -MERN Marketplace 上的卖家可以创建店铺并向每个店铺添加产品。为了存储店铺数据并启用店铺管理,我们将为店铺实施 Mongoose 模式、访问和修改店铺数据的后端 API,以及店铺所有者和买家浏览市场的前端视图。 - -# 商店模型 - -`server/models/shop.model.js`中定义的店铺模式将具有存储店铺详细信息的简单字段,以及一个徽标图像和对拥有店铺的用户的引用。 - -* **店铺名称和说明**:名称和说明字段为字符串类型,必填字段为`name`: - -```jsx -name: { - type: String, - trim: true, - required: 'Name is required' -}, -description: { - type: String, - trim: true -}, -``` - -* **店铺 logo 图像**:`image`字段将用户上传的 logo 图像文件作为数据存储在 MongoDB 数据库中: - -```jsx -image: { - data: Buffer, - contentType: String -}, -``` - -* **店主**:店主字段将引用正在创建店铺的用户: - -```jsx -owner: { - type: mongoose.Schema.ObjectId, - ref: 'User' -} -``` - -* **在**时刻创建更新:`created`和`updated`字段为`Date`类型,新增店铺时生成`created`,修改店铺明细时变更`updated`: - -```jsx -updated: Date, -created: { - type: Date, - default: Date.now -}, -``` - -此模式定义中的字段将使我们能够在 MERN Marketplace 中实现所有与商店相关的功能。 - -# 创建一个新商店 - -在 MERN Marketplace 中,登录的用户和卖家将能够创建新店铺。 - -# 创建车间 API - -在后端,我们将添加一个 POST 路由,用于验证当前用户是否是卖家,并使用请求中传递的店铺数据创建一个新店铺。 - -`mern-marketplace/server/routes/shop.routes.js`: - -```jsx -router.route('/api/shops/by/:userId') - .post(authCtrl.requireSignin,authCtrl.hasAuthorization, - userCtrl.isSeller, shopCtrl.create) -``` - -`shop.routes.js`文件将非常类似于`user.routes`文件,要在 Express 应用中加载这些新路线,我们需要在`express.js`中装载店铺路线,就像我们在验证和用户路线中所做的那样。 - -`mern-marketplace/server/express.js`: - -```jsx -app.use('/', shopRoutes) -``` - -我们将更新用户控制器以添加`isSeller`方法,这将确保当前用户在创建新店铺之前实际上是卖家。 - -`mern-marketplace/server/controllers/user.controller.js`: - -```jsx -const isSeller = (req, res, next) => { - const isSeller = req.profile && req.profile.seller - if (!isSeller) { - return res.status('403').json({ - error: "User is not a seller" - }) - } - next() -} -``` - -shop controller 中的`create`方法使用`formidable`npm 模块解析多部分请求,该请求可能包含用户上传的店铺徽标图像文件。如果有文件,`formidable`会临时存储在文件系统中,我们会使用`fs`模块读取文件类型和数据,将其存储到车间文档的`image`字段中。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const create = (req, res, next) => { - let form = new formidable.IncomingForm() - form.keepExtensions = true - form.parse(req, (err, fields, files) => { - if (err) { - res.status(400).json({ - message: "Image could not be uploaded" - }) - } - let shop = new Shop(fields) - shop.owner= req.profile - if(files.image){ - shop.image.data = fs.readFileSync(files.image.path) - shop.image.contentType = files.image.type - } - shop.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.status(200).json(result) - }) - }) -} -``` - -The logo image file for the shop is uploaded by the user and stored in MongoDB as data. Then, in order to be shown in the views, it is retrieved from the database as an image file at a separate GET API. The GET API is set up as an Express route at `/api/shops/logo/:shopId`, which gets the image data from MongoDB and sends it as a file in the response. The implementation steps for file upload, storage, and retrieval are outlined in detail in the *Upload profile photo* section in [Chapter 5](05.html), *Starting with a Simple Social Media Application*. - -# 在视图中获取创建 API - -在前端,为了使用这个创建 API,我们将在`client/shop/api-shop.js`中设置一个`fetch`方法,通过传递多部分表单数据对创建 API 进行 post 请求: - -```jsx -const create = (params, credentials, shop) => { - return fetch('/api/shops/by/'+ params.userId, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: shop - }) - .then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 新闻商店组件 - -在`NewShop`组件中,我们将呈现一个表单,允许卖家通过输入名称和描述并从其本地文件系统上载徽标图像文件来创建店铺: - -![](img/df926141-6696-4db6-ac6e-40059ccba860.png) - -我们将使用 MaterialUI 按钮和 HTML5 文件输入元素添加文件上传元素。 - -`mern-marketplace/client/shop/NewShop.js`: - -```jsx - - - {this.state.image ? this.state.image.name : ''} -``` - -名称和说明表单字段将添加`TextField`组件。 - -`mern-marketplace/client/shop/NewShop.js`: - -```jsx -
- -``` - -这些表单字段更改将通过`handleChange`方法进行跟踪。 - -`mern-marketplace/client/shop/NewShop.js`: - -```jsx -handleChange = name => event => { - const value = name === 'image' - ? event.target.files[0] - : event.target.value - this.shopData.set(name, value) - this.setState({ [name]: value }) -} -``` - -`handleChange`方法使用新值更新状态并填充`shopData`,这是一个`FormData`对象,确保数据以`multipart/form-data`编码类型所需的正确格式存储。`shopData`对象在`componentDidMount`中初始化。 - -`mern-marketplace/client/shop/NewShop.js`: - -```jsx -componentDidMount = () => { - this.shopData = new FormData() -} -``` - -在表单提交时,在`clickSubmit`函数中调用`create`获取方法。 - -`mern-marketplace/client/shop/NewShop.js`: - -```jsx - clickSubmit = () => { - const jwt = auth.isAuthenticated() - create({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.shopData).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({error: '', redirect: true}) - } - }) - } -``` - -成功创建店铺后,用户将重定向回`MyShops`视图。 - -`mern-marketplace/client/shop/NewShop.js`: - -```jsx -if (this.state.redirect) { - return () -} -``` - -`NewShop`组件只能由同时也是卖家的登录用户查看。因此,我们将在`MainRouter`组件中添加一个`PrivateRoute`,该组件将仅为`/seller/shop/new`处的授权用户呈现此表单。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -此链接可以添加到卖方可以访问的任何视图组件。 - -# 名单商店 - -在 MERN Marketplace 中,普通用户将能够浏览平台上所有商店的列表,商店所有者将管理自己商店的列表。 - -# 列出所有商店 - -所有商店的列表将从后端获取并显示给最终用户。 - -# 商店名单 API - -在后端,当服务器在`'/api/shops'`收到 GET 请求时,我们会在`server/routes/shop.routes.js`中添加一条路由,检索数据库中存储的所有店铺: - -```jsx -router.route('/api/shops') - .get(shopCtrl.list) -``` - -`shop.controller.js`中的`list`控制器方法将查询数据库中的店铺集合,返回所有店铺。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const list = (req, res) => { - Shop.find((err, shops) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(shops) - }) -} -``` - -# 把所有的商店都找来看看 - -在前端,为了使用此列表 API 获取店铺,我们将在`client/shop/api-shop.js`中设置一个`fetch`方法: - -```jsx -const list = () => { - return fetch('/api/shops', { - method: 'GET', - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 车间组件 - -在`Shops`组件中,我们将在组件挂载时获取数据并将数据设置为状态后,在`List`物料界面中呈现店铺列表: - -![](img/c67be761-9fe9-4ad4-bd31-e1f4418fb682.png) - -在`componentDidMount`中调用`loadShops`方法,在组件安装时加载车间。 - -`mern-marketplace/client/shop/Shops.js`: - -```jsx -componentDidMount = () => { - this.loadShops() -} -``` - -它使用`list`fetch 方法检索车间列表,并将数据设置为 state。 - -`mern-marketplace/client/shop/Shops.js`: - -```jsx -loadShops = () => { - list().then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({shops: data}) - } - }) - } -``` - -在`Shops`组件中,使用`map`迭代检索到的店铺数组,每个店铺的数据呈现在物料 UI`ListItem`的视图中,每个`ListItem`也链接到单个店铺的视图。 - -`mern-marketplace/client/shop/Shops.js`: - -```jsx -{this.state.shops.map((shop, i) => { - return - - - - - -
- - {shop.name} - - - {shop.description} - -
-
- })} -``` - -最终用户将在`/shops/all`访问`Shops`组件,使用 React 路由进行设置,并在`MainRouter.js`中声明。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -# 按业主列出店铺名单 - -授权卖家将看到他们创建的店铺列表,他们可以通过编辑或删除列表中的任何店铺来管理该列表。 - -# 按业主划分的店铺 - -我们将添加一个 GET 路由来检索特定用户拥有的店铺,并将其添加到后端中声明的店铺路由中。 - -`mern-marketplace/server/routes/shop.routes.js`: - -```jsx -router.route('/api/shops/by/:userId') - .get(authCtrl.requireSignin, authCtrl.hasAuthorization, shopCtrl.listByOwner) -``` - -为了处理`:userId`参数并从数据库中检索相关用户,我们将使用用户控制器中的`userByID`方法。我们将在`shop.routes.js`中的`Shop`路由中添加以下内容,这样用户在`request`对象中可以作为`profile`使用。 - -`mern-marketplace/server/routes/shop.routes.js`: - -```jsx -router.param('userId', userCtrl.userByID) -``` - -`shop.controller.js`中的`listByOwner`控制器方法查询数据库中的`Shop`集合,得到匹配店铺。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const listByOwner = (req, res) => { - Shop.find({owner: req.profile._id}, (err, shops) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(shops) - }).populate('owner', '_id name') -} -``` - -在对店铺集合的查询中,我们找到了所有店铺,`owner`字段与使用`userId`参数指定的用户匹配。 - -# 获取视图中用户拥有的所有店铺 - -在前端,为了使用此 list by owner API 获取特定用户的店铺,我们将在`client/shop/api-shop.js`中添加一个获取方法: - -```jsx -const listByOwner = (params, credentials) => { - return fetch('/api/shops/by/'+params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# MyShops 组件 - -`MyShops`组件与`Shops`组件类似,它在`componentDIdMount`中获取当前用户拥有的店铺列表,并在`ListItem`中呈现每个店铺: - -![](img/616c4231-1720-4919-beac-fe405b705498.png) - -此外,每个商店都有一个`edit`和`delete`选项,这与`shops`中的项目列表不同。 - -`mern-marketplace/client/shop/MyShops.js`: - -```jsx - - - - - - - - -``` - -`Edit`按钮链接到编辑车间视图。`DeleteShop`组件处理删除操作,通过调用`MyShops`传递的`removeShop`方法更新列表,用当前用户修改后的店铺列表更新状态。 - -`mern-marketplace/client/shop/MyShops.js`: - -```jsx -removeShop = (shop) => { - const updatedShops = this.state.shops - const index = updatedShops.indexOf(shop) - updatedShops.splice(index, 1) - this.setState({shops: updatedShops}) -} -``` - -`MyShops`组件只能由同时也是卖家的登录用户查看。因此,我们将在`MainRouter`组件中添加一个`PrivateRoute`,该组件将在`/seller/shops`处仅为授权用户呈现此组件。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -# 陈列商店 - -任何浏览 MERN Marketplace 的用户都可以浏览每个单独的商店。 - -# 阅读商店 API - -在后端,我们将添加一个`GET`路由,该路由使用 ID 查询`Shop`集合,并在响应中返回商店。 - -`mern-marketplace/server/routes/shop.routes.js`: - -```jsx -router.route('/api/shop/:shopId') - .get(shopCtrl.read) -router.param('shopId', shopCtrl.shopByID) -``` - -路由 URL 中的`:shopId`参数将调用`shopByID`控制器方法,类似于`userByID`控制器方法,从数据库中检索店铺,并将其附加到`next`方法中使用的请求对象。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const shopByID = (req, res, next, id) => { - Shop.findById(id).populate('owner', '_id name').exec((err, shop) => { - if (err || !shop) - return res.status('400').json({ - error: "Shop not found" - }) - req.shop = shop - next() - }) -} -``` - -然后,`read`控制器方法在响应中将此`shop`对象返回给客户端。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const read = (req, res) => { - return res.json(req.shop) -} -``` - -# 把商店带到风景区 - -在`api-shop.js`中,我们将在前端添加一个`fetch`方法来使用这个读取 API。 - -`mern-marketplace/client/shop/api-shop.js`: - -```jsx -const read = (params, credentials) => { - return fetch('/api/shop/' + params.shopId, { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => console.log(err) ) -} -``` - -# 车间组件 - -`Shop`组件将使用产品列表组件呈现车间详细信息和指定车间的产品列表,这将在*产品*部分中讨论: - -![](img/ea0dcb6f-6c84-49b8-b545-6f7fb64e2d01.png) - -在浏览器中可以通过`/shops/:shopId`路径访问`Shop`组件,该路径在`MainRouter`中定义如下。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -在`componentDidMount`中,使用`read`方法从`api-shop.js`获取店铺详细信息。 - -`mern-marketplace/client/shop/Shop.js`: - -```jsx -componentDidMount = () => { - read({ - shopId: this.match.params.shopId - }).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({shop: data}) - } - }) -} -``` - -检索到的店铺数据设置为状态,并在视图中呈现,以显示店铺名称、徽标和描述。 - -`mern-marketplace/client/shop/Shop.js`: - -```jsx - - - {this.state.shop.name} -
-
- - {this.state.shop.description} -
-
-``` - -`logoUrl`指向从数据库中检索徽标图像(如果存在)的路径,其定义如下。 - -`mern-marketplace/client/shop/Shop.js`: - -```jsx -const logoUrl = this.state.shop._id - ? `/api/shops/logo/${this.state.shop._id}?${new Date().getTime()}` - : '/api/shops/defaultphoto' -``` - -# 编辑商店 - -授权卖家还可以编辑他们拥有的店铺的详细信息。 - -# 编辑商店 API - -在后端,我们将添加一条`PUT`路线,允许授权卖家编辑他们的一家店铺。 - -`mern-marketplace/server/routes/shop.routes.js`: - -```jsx -router.route('/api/shops/:shopId') - .put(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.update) -``` - -`isOwner`控制器方法确保登录用户实际上是正在编辑的店铺的所有者。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const isOwner = (req, res, next) => { - const isOwner = req.shop && req.auth && req.shop.owner._id == - req.auth._id - if(!isOwner){ - return res.status('403').json({ - error: "User is not authorized" - }) - } - next() -} -``` - -`update`控制器方法将使用前面讨论的`create`控制器方法中的`formidable`和`fs`模块来解析表单数据并更新数据库中的现有店铺。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const update = (req, res, next) => { - let form = new formidable.IncomingForm() - form.keepExtensions = true - form.parse(req, (err, fields, files) => { - if (err) { - res.status(400).json({ - message: "Photo could not be uploaded" - }) - } - let shop = req.shop - shop = _.extend(shop, fields) - shop.updated = Date.now() - if(files.image){ - shop.image.data = fs.readFileSync(files.image.path) - shop.image.contentType = files.image.type - } - shop.save((err) => { - if (err) { - return res.status(400).send({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(shop) - }) - }) -} -``` - -# 在视图中获取编辑 API - -在视图中使用`fetch`方法调用编辑 API,该方法获取表单数据并将多部分请求发送到后端。 - -`mern-marketplace/client/shop/api-shop.js`: - -```jsx -const update = (params, credentials, shop) => { - return fetch('/api/shops/' + params.shopId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: shop - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 编辑车间组件 - -`EditShop`组件将显示一个类似于创建新店铺表单的表单,预先填充了现有店铺详细信息。此组件还将显示本店的产品列表,将在*产品*部分讨论: - -![](img/7d0e26cb-2b0f-4f2f-8ba5-21e567bf8905.png) - -表单部分类似于`NewShop`组件中的表单,具有相同的表单字段和一个`formData`对象,该对象保存通过`update`获取方法发送的多部分表单数据。 - -`EditShop`组件只能由授权的店主访问。因此我们将在`MainRouter`组件中添加一个`PrivateRoute`,该组件将仅在`/seller/shop/edit/:shopId`为授权用户呈现该组件。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -此链接为`MyShops`组件中的每个店铺添加了一个编辑图标。 - -# 删除店铺 - -授权卖家可以从`MyShops`列表中删除自己的任何店铺。 - -# 删除商店 API - -在后端,我们将添加一条`DELETE`路线,允许授权卖家删除他们自己的一家店铺。 - -`mern-marketplace/server/routes/shop.routes.js`: - -```jsx -router.route('/api/shops/:shopId') - .delete(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.remove) -``` - -如果`isOwner`确认登录用户是店铺的所有者,则`remove`控制器方法从数据库中删除指定店铺。 - -`mern-marketplace/server/controllers/shop.controller.js`: - -```jsx -const remove = (req, res, next) => { - let shop = req.shop - shop.remove((err, deletedShop) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) -``` - -```jsx - }) - } - res.json(deletedShop) - }) -} -``` - -# 在视图中获取删除 API - -我们将在前端添加一个相应的方法,向 deleteapi 发出删除请求。 - -`mern-marketplace/client/shop/api-shop.js`: - -```jsx -const remove = (params, credentials) => { - return fetch('/api/shops/' + params.shopId, { - method: 'DELETE', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 删除车间组件 - -列表中每个车间的`DeleteShop`组件添加到`MyShops`组件中。它以`shop`对象和`onRemove`方法作为`MyShops`的道具: - -![](img/9dc19d06-d474-4b66-bbaa-070dd8d16b12.png) - -这个组件基本上是一个图标按钮,单击它会打开一个确认对话框,询问用户是否确定要删除他们的店铺。 - -`mern-marketplace/client/shop/DeleteShop.js`: - -```jsx - - - - - {"Delete "+this.props.shop.name} - - - Confirm to delete your shop {this.props.shop.name}. - - - - - - - -``` - -在对话框中用户确认删除后,在`deleteShop`中调用`delete`获取方法。 - -`mern-marketplace/client/shop/DeleteShop.js`: - -```jsx - deleteShop = () => { - const jwt = auth.isAuthenticated() - remove({ - shopId: this.props.shop._id - }, {t: jwt.token}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({open: false}, () => { - this.props.onRemove(this.props.shop) - }) - } - }) - } -``` - -删除成功后,对话框关闭,`MyShops`中的店铺列表通过调用`onRemove`道具更新,该道具将`removeShop`方法作为道具从`MyShops`传入。 - -这些店铺视图将允许买家和卖家与店铺互动。商店还将有产品,下面将讨论,所有者将管理这些产品,购买者将浏览这些产品,并选择将其添加到购物车中。 - -# 产品 - -产品是市场应用中最关键的方面。在 MERN Marketplace 中,卖家可以在其店铺中管理产品,访客可以搜索和浏览产品。 - -# 产品模型 - -产品将存储在数据库中的产品集合中,使用 Mongoose 定义模式。对于 MERN Marketplace,我们将保持产品模式的简单性,并支持诸如产品名称、描述、图像、类别、数量、价格、创建地点、更新地点和商店参考等字段。 - -* **产品名称及说明**:`name`和`description`字段为`String`类型,其中`name`字段为`required`字段: - -```jsx -name: { - type: String, - trim: true, - required: 'Name is required' -}, -description: { - type: String, - trim: true -}, -``` - -* **产品图片**:`image`字段将用户上传的图片文件作为数据存储在 MongoDB 数据库中: - -```jsx -image: { - data: Buffer, - contentType: String -}, -``` - -* **产品类别**:`category`值允许将相同类型的产品分组在一起: - -```jsx -category: { - type: String -}, -``` - -* **产品数量**:`quantity`字段表示店铺可供销售的金额: - -```jsx -quantity: { - type: Number, - required: "Quantity is required" -}, -``` - -* **产品价格**:`price`字段将保存此产品将花费买方的单价: - -```jsx -price: { - type: Number, - required: "Price is required" -}, -``` - -* **产品车间**:`shop`字段将引用添加产品的车间: - -```jsx -shop: { - type: mongoose.Schema.ObjectId, - ref: 'Shop' -} -``` - -* **在**时间创建更新:`created`和`updated`字段为`Date`类型,新增产品时生成`created`,修改同一产品明细时`updated`时间发生变化: - -```jsx -updated: Date, -created: { - type: Date, - default: Date.now -}, -``` - -此模式定义中的字段将使我们能够在 MERN Marketplace 中实现所有与产品相关的功能。 - -# 创建新产品 - -MERN Marketplace 的卖家将能够向他们拥有的店铺添加新产品,并在平台上创建新产品。 - -# 创建产品 API - -在后端,我们将在`/api/products/by/:shopId`添加一个路由,该路由接受包含产品数据的`POST`请求,以创建与`:shopId`参数标识的店铺关联的新产品。在数据库中创建新产品之前,处理此请求的代码将首先检查当前用户是否是要添加新产品的商店的所有者。 - -此创建产品 API 路由在`product.routes.js`文件中声明,它使用来自车间控制器的`shopByID`和`isOwner`方法来处理`:shopId`参数,并验证当前用户是否为车间所有者。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products/by/:shopId') - .post(authCtrl.requireSignin, - shopCtrl.isOwner, - productCtrl.create) -router.param('shopId', shopCtrl.shopByID) -``` - -`product.routes.js`文件将非常类似于`shop.routes.js`文件,要在 Express app 中加载这些新路线,我们需要在`express.js`中装载产品路线,就像我们在商店路线中所做的那样。 - -`mern-marketplace/server/express.js`: - -```jsx -app.use('/', productRoutes) -``` - -产品控制器中的`create`方法使用`formidable`npm 模块解析可能包含用户上传的图像文件以及产品字段的多部分请求。解析后的数据作为新产品保存到`Product`集合中。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const create = (req, res, next) => { - let form = new formidable.IncomingForm() - form.keepExtensions = true - form.parse(req, (err, fields, files) => { - if (err) { - return res.status(400).json({ - message: "Image could not be uploaded" - }) - } - let product = new Product(fields) - product.shop= req.shop - if(files.image){ - product.image.data = fs.readFileSync(files.image.path) - product.image.contentType = files.image.type - } - product.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) - }) -} -``` - -# 在视图中获取创建 API - -在前端,为了使用这个创建 API,我们将在`client/product/api-product.js`中设置一个`fetch`方法,通过从视图传递多部分表单数据向创建 API 发出 post 请求。 - -`mern-marketplace/client/product/api-product.js`: - -```jsx -const create = (params, credentials, product) => { - return fetch('/api/products/by/'+ params.shopId, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: product - }) - .then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 新产品组件 - -`NewProduct`部件与`NewShop`部件相似。它将包含一个表单,允许卖家通过输入名称、描述、类别、数量和价格,并从其本地文件系统上载产品映像文件来创建产品: - -![](img/651b5ab8-bc62-48ac-8ff4-c5acdfeb28ea.png) - -此`NewProduct`组件将仅在与特定店铺关联的路线上加载,因此只有作为卖家的登录用户才能将产品添加到他们拥有的店铺中。为了定义此路由,我们在`MainRouter`组件中添加了一个`PrivateRoute`,它将仅为`/seller/:shopId/products/new`处的授权用户呈现此表单。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -# 列出产品 - -在 MERN Marketplace 中,产品将以多种方式呈现给用户,两个主要区别在于产品为卖家列出的方式和为买家列出的方式。 - -# 按店铺列出 - -市场访客将浏览每家店铺的产品,卖家将管理每家店铺的产品列表。 - -# 按店铺空气污染指数划分的产品 - -要从数据库中的特定商店检索产品,我们将在`/api/products/by/:shopId`处设置一个获取路径,如下所示。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products/by/:shopId') - .get(productCtrl.listByShop) -``` - -响应此请求执行的`listByShop`控制器方法将查询产品集合,以返回与给定店铺参考匹配的产品。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const listByShop = (req, res) => { - Product.find({shop: req.shop._id}, (err, products) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(products) - }).populate('shop', '_id name').select('-image') -} -``` - -在前端,为了通过 shop API 使用此列表获取特定店铺中的产品,我们将在`api-product.js`中添加一个获取方法 - -`mern-marketplace/client/product/api-product.js`: - -```jsx -const listByShop = (params) => { - return fetch('/api/products/by/'+params.shopId, { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 面向买家的产品组件 - -`Products`组件主要用于向可能购买产品的访客展示产品。我们将使用此组件呈现与买方相关的产品列表。它将从显示产品列表的父组件接收产品列表作为道具: - -![](img/ac725381-88fc-4ed2-8d4e-73c76d340bf1.png) - -商店中的产品列表将以单个`Shop`视图显示给用户。所以这个`Products`组件被添加到`Shop`组件中,并作为道具给出了相关产品的列表。`searched`道具会传递此列表是否是产品搜索的结果,因此可以呈现适当的消息。 - -`mern-marketplace/client/shop/Shop.js`: - -```jsx - -``` - -在`Shop`组件中,我们需要在`componentDidMount`上添加对`listByShop`fetch 方法的调用,以检索相关产品并将其设置为 state。 - -`mern-marketplace/client/shop/Shop.js`: - -```jsx -listByShop({ - shopId: this.match.params.shopId - }).then((data)=>{ - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({products: data}) - } -}) -``` - -在`Products`组件中,如果道具中发送的产品列表包含产品,则该列表将被迭代,并在物料界面`GridListTile`中呈现每个产品的相关详细信息,其中链接到单个产品视图和`AddToCart`组件(具体实现在[第 7 章](07.html)、*中讨论)扩展订单和付款市场*。 - -`mern-marketplace/client/product/Products.js`: - -```jsx -{this.props.products.length > 0 ? - (
- {this.props.products.map((product, i) => ( - - - {product.name} - - {product.name} - } - subtitle={$ {product.price}} - actionIcon={} - /> - - ))} -
) : this.props.searched && - ( - No products found! :()} -``` - -此`Products`组件用于呈现商店中的产品、按类别划分的产品以及搜索结果中的产品。 - -# 店主的 MyProducts 组件 - -与`Products`组件不同,`client/product/MyProducts.js`中的`MyProducts`组件只用于向卖家展示产品,以便卖家管理每家店铺的产品: - -![](img/1179071d-86e9-4026-8dac-527e64f4fe67.png) - -`MyProducts`组件添加到`EditShop`视图中,因此卖家可以在一个地方管理店铺及其内容。道具中提供了店铺 ID,因此可以获取相关产品。 - -`mern-marketplace/client/shop/EditShop.js`: - -```jsx - -``` - -在`MyProducts`中,相关产品首先加载在`componentDidMount`中。 - -`mern-marketplace/client/product/MyProducts.js`: - -```jsx -componentDidMount = () => { - this.loadProducts() -} -``` - -`loadProducts`方法使用相同的`listByShop`提取方法检索商店中的产品,并将其设置为状态。 - -`mern-marketplace/client/product/MyProducts.js`: - -```jsx -loadProducts = () => { - listByShop({ - shopId: this.props.shopId - }).then((data)=>{ - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({products: data}) - } - }) -} -``` - -此产品列表被迭代,每个产品在`ListItem`中呈现,并带有编辑和删除选项,类似于`MyShops`列表视图。编辑按钮链接到编辑产品视图。`DeleteProduct`组件处理删除操作,并通过调用`MyProducts`传递的`onRemove`方法重新加载列表,用当前店铺的更新后的产品列表更新状态。 - -`MyProducts`中定义的`removeProduct`方法作为`DeleteProduct`组件的`onRemove`道具提供。 - -`mern-marketplace/client/product/MyProducts.js`: - -```jsx -removeProduct = (product) => { - const updatedProducts = this.state.products - const index = updatedProducts.indexOf(product) - updatedProducts.splice(index, 1) - this.setState({shops: updatedProducts}) -} -... - - -``` - -# 列出产品建议 - -MERN Marketplace 的访问者将看到产品建议,例如添加到市场的最新产品以及与他们当前查看的产品相关的产品。 - -# 最新产品 - -在 MERN Marketplace 的主页上,我们将展示添加到市场中的五种最新产品。为了获取最新的产品,我们将设置一个 API,该 API 将在`/api/products/latest`接收 GET 请求。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products/latest') - .get(productCtrl.listLatest) -``` - -`listLatest`控制器方法将按照`created`日期从最新到最旧对数据库中的产品列表进行排序,并在响应中返回排序后的列表中的前五个。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const listLatest = (req, res) => { - Product.find({}).sort('-created').limit(5).populate('shop', '_id - name').exec((err, products) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(products) - }) -} -``` - -在前端,我们将为这个最新的`products`API 在`api-product.js`中设置一个对应的获取方法,类似于`fetch`中通过店铺获取列表的方法。检索到的列表将在添加到主页的`Suggestions`组件中呈现。 - -# 相关产品 - -在每个产品视图中,我们将显示五个相关产品作为建议。为了检索这些相关产品,我们将在`/api/products/related`设置一个接受请求的 API。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products/related/:productId') - .get(productCtrl.listRelated) -router.param('productId', productCtrl.productByID) -``` - -route URL route 中的`:productId`参数将调用`productByID`控制器方法,该方法类似于`shopByID`控制器方法,并从数据库中检索产品并将其附加到`next`方法中要使用的请求对象。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const productByID = (req, res, next, id) => { - Product.findById(id).populate('shop', '_id name').exec((err, product) => { - if (err || !product) - return res.status('400').json({ - error: "Product not found" - }) - req.product = product - next() - }) -} -``` - -`listRelated`控制器方法查询`Product`集合,查找与给定产品类别相同的其他产品,不包括给定产品,并返回结果列表中的前五个产品。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const listRelated = (req, res) => { - Product.find({ "_id": { "$ne": req.product }, - "category": req.product.category}).limit(5) - .populate('shop', '_id name') - .exec((err, products) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(products) - }) -} -``` - -为了在前端使用此相关产品 API,我们将在`api-product.js`中设置相应的获取方法。fetch 方法将在具有产品 ID 的`Product`组件中调用,以填充在产品视图中呈现的`Suggestions`组件。 - -# 建议部分 - -`Suggestions`组件将显示在主页和单个产品页面上,分别显示最新产品和相关产品: - -![](img/81546059-ce0f-4787-9d3c-a6c9d41cb1de.png) - -它将收到来自父组件的相关产品列表作为道具,以及列表标题: - -```jsx - -``` - -在`Suggestions`组件中,对接收到的列表进行迭代,并以相关详细信息、单个产品页面链接和`AddToCart`组件呈现单个产品。 - -`mern-marketplace/client/product/Suggestions.js`: - -```jsx - {this.props.title} -{this.props.products.map((item, i) => { - return - - - - - - {item.name} - - - - shopping_basket {item.shop.name} - - - - Added on {(new - Date(item.created)).toDateString()} - - - $ - {item.price} - - - - - - - - })} -``` - -# 展示产品 - -MERN Marketplace 的访问者将能够浏览每个产品,并在单独的视图中显示更多详细信息。 - -# 阅读产品 API - -在后端,我们将添加一个 GET 路由,该路由使用 ID 查询`Product`集合,并在响应中返回产品。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products/:productId') - .get(productCtrl.read) -``` - -`:productId`参数调用`productByID`控制器方法,该方法从数据库检索产品并将其附加到请求对象。`read`控制器方法使用请求对象中的产品响应`read`请求。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const read = (req, res) => { - req.product.image = undefined - return res.json(req.product) -} -``` - -在`api-product.js`中,我们将在前端添加一个 fetch 方法来使用这个 read API。 - -`mern-marketplace/client/product/api-product.js`: - -```jsx -const read = (params) => { - return fetch('/api/products/' + params.productId, { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 产品组件 - -`Product`组件将呈现产品详细信息,包括添加到购物车选项,并显示相关产品的列表: - -![](img/5ade3fb1-cdb7-40a3-8d9c-75a03b55131d.png) - -在浏览器中可以通过`/product/:productID`路径访问`Product`组件,该路径在`MainRouter`中定义如下。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -当用户点击相关列表中的另一个产品后,`productId`在前端路由路径中发生变化时,组件安装时会获取产品详细信息和相关列表数据,或者会收到新的道具。 - -`mern-marketplace/client/product/Product.js`: - -```jsx - componentDidMount = () => { - this.loadProduct(this.match.params.productId) - } - componentWillReceiveProps = (props) => { - this.loadProduct(props.match.params.productId) - } -``` - -`loadProduct`方法调用`read`和`listRelated`获取方法获取产品和相关列表数据,然后将数据设置为状态。 - -`mern-marketplace/client/product/Product.js`: - -```jsx -loadProduct = (productId) => { - read({productId: productId}).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({product: data}) - listRelated({ - productId: data._id}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({suggestions: data}) - } - }) - } - }) -} -``` - -组件的 product details 部分在物料 UI`Card`组件中显示产品和`AddToCart`组件的相关信息。 - -`mern-marketplace/client/product/Product.js`: - -```jsx - - } - title={this.state.product.name} - subheader={this.state.product.quantity > 0? 'In Stock': 'Out of - Stock'} - /> - - - {this.state.product.description}
- $ {this.state.product.price} - - shopping_basket {this.state.product.shop.name} - -
-
-... - -``` - -`Suggestions`组件添加到产品视图中,相关列表数据作为道具传递。 - -# 编辑和删除产品 - -如前几节所述,在应用中编辑和删除产品的实现类似于编辑和删除商店。这些功能需要在后端使用相应的 API,在前端使用获取方法,并使用表单和操作对组件视图进行反应 - -# 编辑 - -编辑功能与创建产品非常相似,`EditProduct`表单组件也只能由`/seller/:shopId/:productId/edit`上的验证卖家访问。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -`EditProduct`组件包含与`NewProduct`相同的表单,使用读取的产品 API 检索产品的填充值,并使用 fetch 方法将包含 PUT 请求的多部分表单数据发送到后端`/api/products/by/:shopId`的编辑产品 API。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/product/:shopId/:productId') - .put(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.update) -``` - -`update`控制器类似于产品`create`方法和车间`update`方法;它使用`formidable`处理多部分表单数据,并扩展产品详细信息以保存更新。 - -# 删去 - -如前所述,`DeleteProduct`组件添加到列表中每个产品的`MyProducts`组件中。它将`product`对象`shopID`和`loadProducts`方法作为`MyProducts`的道具。该组件类似于`DeleteShop`,当用户确认删除意图后,调用取数方法进行删除,向`/api/product/:shopId/:productId`服务器发出删除请求。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/product/:shopId/:productId') - .delete(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.remove) -``` - -# 带类别的产品搜索 - -在 MERN Marketplace 中,访问者可以按名称和特定类别搜索特定产品。 - -# 类别 API - -为了允许用户选择要搜索的特定类别,我们将设置一个 API 来检索数据库中`Product`集合中存在的所有不同类别。对`/api/products/categories`的 GET 请求将返回一个唯一类别数组。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products/categories') - .get(productCtrl.listCategories) -``` - -`listCategories`控制器方法通过`distinct`调用`category`字段查询`Product`集合。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const listCategories = (req, res) => { - Product.distinct('category',{},(err, products) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(products) - }) -} -``` - -此 categories API 可在前端与相应的获取方法一起使用,以检索不同类别的数组并在视图中显示。 - -# 搜索产品 API - -search products API 将在`/api/products?search=value&category=value`接受 GET 请求,URL 中有查询参数,用提供的搜索文本和类别值查询`Product`集合。 - -`mern-marketplace/server/routes/product.routes.js`: - -```jsx -router.route('/api/products') - .get(productCtrl.list) -``` - -`list`控制器方法将首先处理请求中的查询参数,然后查找给定类别中的产品(如果有),其名称与提供的搜索文本部分匹配。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const list = (req, res) => { - const query = {} - if(req.query.search) - query.name = {'$regex': req.query.search, '$options': "i"} - if(req.query.category && req.query.category != 'All') - query.category = req.query.category - Product.find(query, (err, products) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(products) - }).populate('shop', '_id name').select('-image') -} -``` - -# 获取视图的搜索结果 - -为了在前端使用此搜索 API,我们将设置一个方法,该方法使用查询参数构造 URL 并调用 API 的获取。 - -`mern-marketplace/client/product/api-product.js`: - -```jsx -import queryString from 'query-string' -const list = (params) => { - const query = queryString.stringify(params) - return fetch('/api/products?'+query, { - method: 'GET', - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -为了以正确的格式构造查询参数,我们将使用`query-string`npm 模块,这将有助于将 params 对象字符串化为可附加到请求路由的查询字符串。 - -# 搜索组件 - -应用 categories API 和 search API 的第一个用例是`Search`组件: - -![](img/c2c169cb-0551-44c2-b0ac-862e033f86ac.png) - -`Search`组件为用户提供了一个简单的表单,其中包含一个搜索`input`文本字段和从父组件接收的类别选项下拉列表,父组件将使用 distinct categories API 检索列表。 - -`mern-marketplace/client/product/Search.js`: - -```jsx - - All - {this.props.categories.map(option => ( - {option} - ))} - - - - - -``` - -一旦用户输入搜索文本并点击*Enter*,就会调用搜索 API 来检索结果。 - -`mern-marketplace/client/product/Search.js`: - -```jsx -search = () => { - if(this.state.search){ - list({ - search: this.state.search || undefined, category: - this.state.category - }).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({results: data, searched:true}) - } - }) - } - } -``` - -然后将结果数组作为道具传递给`Products`组件,以呈现搜索表单下方的匹配产品。 - -# 类别组件 - -`Categories`组件是不同类别和搜索 API 的第二个用例。对于该组件,我们首先获取父组件中的类别列表,并将其作为道具发送,以向用户显示类别: - -![](img/6f0f8fd3-ab5c-4441-8e25-b50c38df2bf8.png) - -当用户在显示的列表中选择一个类别时,只使用一个类别值调用搜索 API,后端返回所选类别中的所有产品。然后返回的产品在`Products`组件中呈现。 - -在 MERN Marketplace 的第一个版本中,用户可以成为卖家来创建店铺和添加产品,访问者可以浏览店铺和搜索产品,同时应用还向访问者推荐产品。 - -# 总结 - -在本章中,我们开始使用 MERN 堆栈构建一个在线市场应用。MERN 骨架被扩展为向用户添加卖家角色,这样用户就可以创建商店并向每个商店添加产品,以便销售给其他用户。我们还探讨了如何利用堆栈来实现产品浏览、搜索等功能,并为对购买感兴趣的普通用户提供建议。但是,如果没有用于结账、订单管理和付款处理的购物车,市场应用是不完整的。 - -在下一章中,我们将扩展应用以添加这些功能,并进一步了解如何使用 MERN 堆栈实现电子商务应用的这些核心方面。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/07.md b/docs/full-stk-react-proj/07.md deleted file mode 100644 index 08c42d9..0000000 --- a/docs/full-stk-react-proj/07.md +++ /dev/null @@ -1,1590 +0,0 @@ -# 七、为市场扩展订单和支付 - -在客户下订单时处理他们的付款并允许卖家管理这些订单是电子商务应用的关键方面。在本章中,我们将通过引入以下功能来扩展上一章中构建的在线市场: - -* 购物车 -* 带条纹的付款处理 -* 订单管理 - -# 带有购物车、付款和订单的 MERN 市场 - -在[第 6 章](06.html)中开发的 MERN Marketplace 应用*将通过在线市场*练习新的 MERN 技能,该应用将被扩展,包括购物车功能、处理信用卡付款的条带集成以及基本的订单管理流程。下面的实现保持简单,以作为开发这些功能的更复杂版本的起点。 - -下面的组件树图显示了构成 MERN Marketplace 前端的所有自定义组件。本章讨论的功能修改了一些现有组件,如`Profile`、`MyShops`、`Products`和`Suggestions`,并添加了新组件,如`AddToCart`、`MyOrders`、`Cart`和`ShopOrders`: - -![](img/2c82bf96-7c16-481d-8ab8-4b86c7dc266c.jpg) - -The code for the complete MERN Marketplace application is available on GitHub [github.com/shamahoque/mern-marketplace](https://github.com/shamahoque/mern-marketplace). You can clone this code and run the application as you go through the code explanations in the rest of this chapter. To get the code for Stripe payments working, you will need to create your own Stripe account and update the `config/config.js` file with your testing values for the Stripe API key, secret key, and Stripe Connect client ID. - -# 购物车 - -MERN Marketplace 的访问者可以通过单击每个产品上的`add to cart`按钮将他们想要购买的产品添加到购物车中。当用户继续浏览市场时,菜单中的购物车图标将指示已添加到购物车中的产品数量。他们还可以通过打开购物车视图来更新购物车内容并开始结账。但要完成结账并下订单,用户需要登录。 - -购物车主要是一个前端功能,因此购物车的详细信息将存储在客户端本地,直到用户在结帐时下单。为了实现购物车功能,我们将在`client/cart/cart-helper.js`中设置助手方法,以帮助使用相关组件操作购物车详细信息。 - -# 添加到购物车 - -`client/Cart/AddToCart.js`中的`AddToCart`组件将`product`对象和 CSS 样式对象作为其添加到的父组件的道具。例如,在 MERN Marketplace 中,它被添加到产品视图中,如下所示: - -```jsx - -``` - -`AddToCart`组件本身显示一个购物车图标按钮,具体取决于传递的商品是否有库存: - -![](img/068f6b31-5b72-4764-b4e7-3ed9e6e2a194.png) - -例如,如果项目数量大于`0`,则显示`AddCartIcon`,否则显示`DisabledCartIcon`。 - -`mern-marketplace/client/cart/AddToCart.js`: - -```jsx -{this.props.item.quantity >= 0 ? - - - : - - } -``` - -点击`AddCartIcon`按钮时调用`addToCart`方法。 - -`mern-marketplace/client/cart/AddToCart.js`: - -```jsx -addToCart = () => { - cart.addItem(this.props.item, () => { - this.setState({redirect:true}) - }) -} -``` - -`cart-helper.js`中定义的`addItem`助手方法,以`product`项和状态更新`callback`函数为参数,将更新后的购物车明细存储在`localStorage`中,并执行传递的回调。 - -`mern-marketplace/client/cart/cart-helper.js`: - -```jsx -addItem(item, cb) { - let cart = [] - if (typeof window !== "undefined") { - if (localStorage.getItem('cart')) { - cart = JSON.parse(localStorage.getItem('cart')) - } - cart.push({ - product: item, - quantity: 1, - shop: item.shop._id - }) - localStorage.setItem('cart', JSON.stringify(cart)) - cb() - } -} -``` - -`localStorage`中存储的购物车数据包含一个购物车项目对象数组,每个对象包含产品详细信息、添加到购物车的产品数量(默认设置为`1`)以及产品所属商店的 ID。 - -# 菜单上的购物车图标 - -在菜单中,我们将添加到购物车视图的链接,并添加一个显示`localStorage`中存储的购物车数组长度的徽章,以便直观地告知用户当前购物车中有多少物品: - -![](img/6785911f-d5df-4b43-b833-8b329fd4f2d8.png) - -购物车的链接与菜单中的其他链接类似,但显示购物车长度的物料界面`Badge`组件除外。 - -`mern-marketplace/client/core/Menu.js`: - -```jsx - - - -``` - -购物车长度由`cart-helper.js`中的`itemTotal`助手方法返回,该方法读取`localStorage`中存储的购物车数组并返回数组的长度。 - -`mern-marketplace/client/cart/cart-helper.js`: - -```jsx -itemTotal() { - if (typeof window !== "undefined") { - if (localStorage.getItem('cart')) { - return JSON.parse(localStorage.getItem('cart')).length - } - } - return 0 -} -``` - -# 购物车视图 - -购物车视图将包含购物车项目和结帐详细信息,但在用户准备结帐之前,最初仅显示购物车详细信息。 - -`mern-marketplace/client/cart/Cart.js`: - -```jsx - - - - - {this.state.checkout && - - - } - -``` - -`CartItems`组件被传递一个`checkout`布尔值,以及该签出值的状态更新方法,以便`Checkout`组件和选项可以基于用户交互进行呈现。 - -`mern-marketplace/client/cart/Cart.js`: - -```jsx -setCheckout = val =>{ - this.setState({checkout: val}) -} -``` - -`Cart`组件将在`/cart`路径访问,因此我们需要在`MainRouter`组件中添加一个`Route`,如下所示。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -# CartItems 组件 - -`CartItems`组件将允许用户查看和更新购物车中当前的物品。如果他们已登录,还可以选择启动签出过程: - -![](img/1a9d8699-72ed-420e-9358-b4a009b4d2b2.png) - -如果购物车包含商品,`CartItems`组件将迭代商品并呈现购物车中的商品。如果没有添加任何项目,则购物车视图仅显示购物车为空的消息。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -{this.state.cartItems.length > 0 ? - {this.state.cartItems.map((item, i) => { - ... - … Product details - … Edit quantity - … Remove product option - ... - }) - } - … Show total price and Checkout options … - : - - No items added to your cart. - -} -``` - -每个产品项显示产品的详细信息和可编辑的数量文本字段,以及删除项选项。最后,它显示购物车中物品的总价和开始结账的选项。 - -# 检索购物车详细信息 - -`cart-helper.js`中的`getCart`助手方法从`localStorage`检索并返回购物车详细信息。 - -`mern-marketplace/client/cart/cart-helper.js`: - -```jsx -getCart() { - if (typeof window !== "undefined") { - if (localStorage.getItem('cart')) { - return JSON.parse(localStorage.getItem('cart')) - } - } - return [] -} -``` - -在`CartItems`组件中,我们将使用`componentDidMount`中的`getCart`助手方法检索购物车项目,并将其设置为状态。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -componentDidMount = () => { - this.setState({cartItems: cart.getCart()}) -} -``` - -然后使用`map`函数对从`localStorage`检索到的`cartItems`数组进行迭代,以呈现每个项目的细节。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx - - - - - - - {item.product.name} - - - $ {item.product.price} - - ${item.product.price * item.quantity} - Shop: {item.product.shop.name} - -
- … Editable quantity … - … Remove item option ... -
-
- -
-``` - -# 修改量 - -为每个购物车项目呈现的可编辑数量`TextField`允许用户更新他们正在购买的每个产品的数量,并设置允许的最小值`1`。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -Quantity: -``` - -当用户更新此值时,调用`handleChange`方法来执行最小值验证,更新`cartItems`处于状态,并使用 helper 方法更新`localStorage`中的购物车。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -handleChange = index => event => { - let cartItems = this.state.cartItems - if(event.target.value == 0){ - cartItems[index].quantity = 1 - }else{ - cartItems[index].quantity = event.target.value - } - this.setState({cartItems: cartItems}) - cart.updateCart(index, event.target.value) - } -``` - -`updateCart`helper 方法以 cart 数组中正在更新的产品的索引和新的数量值为参数,更新`localStorage`中存储的详细信息。 - -`mern-marketplace/client/cart/cart-helper.js`: - -```jsx -updateCart(itemIndex, quantity) { - let cart = [] - if (typeof window !== "undefined") { - if (localStorage.getItem('cart')) { - cart = JSON.parse(localStorage.getItem('cart')) - } - cart[itemIndex].quantity = quantity - localStorage.setItem('cart', JSON.stringify(cart)) - } -} -``` - -# 删除项目 - -为购物车中的每个项目呈现的移除项目选项是一个按钮,单击该按钮时,会将项目的数组索引传递给`removeItem`方法,以便将其从数组中移除。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx - -``` - -`removeItem`点击处理程序方法使用`removeItem`助手方法从`localStorage`中的购物车中移除该项目,然后更新`cartItems`的状态。此方法还检查购物车是否已清空,因此可以使用作为道具从`Cart`组件传递的`setCheckout`函数隐藏签出。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -removeItem = index => event =>{ - let cartItems = cart.removeItem(index) - if(cartItems.length == 0){ - this.props.setCheckout(false) - } - this.setState({cartItems: cartItems}) -} -``` - -`cart-helper.js`中的`removeItem`助手方法将要从数组中移除的产品的索引进行拼接,并在返回更新后的`cart`数组之前更新`localStorage`。 - -`mern-marketplace/client/cart/cart-helper.js`: - -```jsx -removeItem(itemIndex) { - let cart = [] - if (typeof window !== "undefined") { - if (localStorage.getItem('cart')) { - cart = JSON.parse(localStorage.getItem('cart')) - } - cart.splice(itemIndex, 1) - localStorage.setItem('cart', JSON.stringify(cart)) - } - return cart -} -``` - -# 显示总价 - -在`CartItems`组件的底部,我们将显示购物车中物品的总价。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -Total: ${this.getTotal()} -``` - -`getTotal`方法将考虑`cartItems`数组中每个项目的单价和数量来计算总价。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -getTotal(){ - return this.state.cartItems.reduce( function(a, b){ - return a + (b.quantity*b.product.price) - }, 0) -} -``` - -# 选择退房 - -用户将看到执行签出的选项,具体取决于他们是否已登录以及是否已打开签出。 - -`mern-marketplace/client/cart/CartItems.js`: - -```jsx -{!this.props.checkout && (auth.isAuthenticated() ? - : - - - ) -} -``` - -当点击 checkout 按钮时,`openCheckout`方法将使用作为道具传递的`setCheckout`方法将`Cart`组件中的 checkout 值设置为`true`: - -```jsx -openCheckout = () => { - this.props.setCheckout(true) -} -``` - -一旦在购物车视图中将结帐值设置为`true`,将呈现`Checkout`组件,以允许用户输入结帐详细信息并下订单。 - -# 使用条带进行支付 - -付款处理需要跨结帐、订单创建和订单管理流程的实现。它还涉及更新买方和卖方的用户数据。在深入研究签出和订单功能的实现之前,我们将简要讨论使用 Stripe 的支付处理选项和注意事项,并了解如何将其集成到 MERN Marketplace 中。 - -# 条纹 - -Stripe 提供了在任何 web 应用中集成支付所需的广泛工具集。根据应用的特定类型和正在实施的支付用例,可以以不同的方式选择和使用这些工具。 - -在 MERN Marketplace 设置的情况下,应用本身将在 Stripe 上有一个平台,并且期望卖家在平台上有连接的 Stripe 帐户,因此应用可以向代表卖家在结账时输入信用卡详细信息的用户收费。在 MERN Marketplace 中,用户可以将来自不同商店的产品添加到他们的购物车中,因此他们的卡上的费用将仅由应用在卖家处理订购的特定产品时创建。此外,卖家将完全控制他们自己的条纹仪表板上代表他们创建的费用。我们将演示如何使用 Stripe 提供的工具使此支付设置正常工作。 - -Stripe 为每个工具提供了一套完整的文档和指南,还公开了在 Stripe 上设置的帐户和平台的测试数据。为了在 MERN Marketplace 中实现支付,我们将使用测试密钥,并让您自行扩展实时支付的实现。 - -# 每个卖家的条带连接帐户 - -为了代表卖家创建费用,应用将允许卖家用户将其 Stripe 帐户连接到其 MERN Marketplace 用户帐户。 - -# 更新用户模型 - -要在用户的条带帐户成功连接后存储条带 OAuth 凭据,我们将使用以下字段更新用户模型。 - -`mern-marketplace/server/models/user.model.js`: - -```jsx -stripe_seller: {} -``` - -`stripe_seller`字段将存储卖家的 Stripe 账户凭证,当需要通过 Stripe 为他们从商店出售的产品处理费用时,将使用该凭证。 - -# 用于连接条带的按钮 - -在卖家的用户配置文件页面中,如果用户尚未连接其条带帐户,我们将显示一个按钮,该按钮将引导用户到条带进行身份验证并连接其条带帐户: - -![](img/c26a53b2-2032-4685-ad5f-b14fd59ef2ad.png) - -如果用户已成功连接其条带帐户,我们将显示禁用的条带连接按钮: - -![](img/1618ca16-5f33-4815-8829-a2816ba2e80c.png) - -添加到`Profile`组件的代码将首先检查用户是否是卖家,然后再呈现任何`STRIPE CONNECTED`按钮。然后,第二次检查将确认给定用户的`stripe_seller`字段中是否已经存在条带凭据。如果用户的条带凭据已经存在,则会显示禁用的`STRIPE CONNECTED`按钮,否则会显示使用 OAuth 链接连接到条带的链接。 - -`mern-marketplace/client/user/Profile.js`: - -```jsx -{this.state.user.seller && - (this.state.user.stripe_seller ? - () : - ( - - ) -)} -``` - -OAuth 链接使用平台的客户端 ID(我们将在`config`变量中设置)和其他选项值作为查询参数。此链接将用户带到条带,并允许用户连接现有条带帐户或创建新的条带帐户。然后,一旦 Stripe 的身份验证过程完成,它将使用 Stripe 上仪表板中平台连接设置中设置的重定向 URL 返回到我们的应用。条带将身份验证代码或错误消息作为查询参数附加到重定向 URL。 - -MERN Marketplace 重定向 URI 设置为`/seller/stripe/connect`,将呈现`StripeConnect`组件。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -# StripeConnect 组件 - -`StripeConnect`组件将使用条带基本完成剩余的身份验证过程步骤,并根据条带连接是否成功呈现相关消息: - -![](img/c0f9a30b-0a63-4f36-80f3-6ef632bbf11e.png) - -当`StripeConnect`组件加载时,在`componentDidMount`中,我们将首先解析从条带重定向附加到 URL 的查询参数。对于解析,我们使用与之前用于产品搜索相同的`query-string`npm 模块。然后,如果 URL`query`参数包含身份验证代码,我们将进行必要的 API 调用,以从服务器完成条带 OAuth。 - -`mern-marketplace/client/user/StripeConnect.js`: - -```jsx - componentDidMount = () => { - const parsed = queryString.parse(this.props.location.search) - if(parsed.error){ - this.setState({error: true}) - } - if(parsed.code){ - this.setState({connecting: true, error: false}) - const jwt = auth.isAuthenticated() - stripeUpdate({ - userId: jwt.user._id - }, { - t: jwt.token - }, parsed.code).then((data) => { - if (data.error) { - this.setState({error: true, connected: false, - connecting:false}) - } else { - this.setState({connected: true, connecting: false, - error:false}) - } - }) - } - } -``` - -`stripeUpdate`获取方法在`api-user.js`中定义,它将从 Stripe 中检索到的身份验证代码传递给我们将在`'/api/stripe_auth/:userId'`服务器中设置的 API。 - -`mern-marketplace/client/user/api-user.js`: - -```jsx -const stripeUpdate = (params, credentials, auth_code) => { - return fetch('/api/stripe_auth/'+params.userId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify({stripe: auth_code}) - }).then((response)=> { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 条带身份验证更新 API - -一旦 Stripe 帐户连接成功,为了完成 OAuth 进程,我们需要使用检索到的身份验证代码从服务器对 Stripe OAuth 进行 POST API 调用,并检索要存储在卖方用户帐户中的凭据以支付处理费用。Stripe auth update API 在`/api/stripe_auth/:userId`接收到一个请求,并启动 POST API 调用以从 Stripe 检索凭据。 - -此条带身份验证更新 API 的路由将在服务器上的用户路由中声明,如下所示。 - -`mern-marketplace/server/routes/user.routes.js`: - -```jsx -router.route('/api/stripe_auth/:userId') - .put(authCtrl.requireSignin, authCtrl.hasAuthorization, - userCtrl.stripe_auth, userCtrl.update) -``` - -对该路由的请求使用`stripe_auth`控制器方法从条带中检索凭据,并将其传递给现有的用户更新方法以存储在数据库中。 - -为了从我们的服务器向条带 API 发出 POST 请求,我们将使用`request`npm 模块: - -```jsx -npm install request --save -``` - -用户控制器中的`stripe_auth`控制器方法如下。 - -`mern-marketplace/server/controllers/user.controller.js`: - -```jsx -const stripe_auth = (req, res, next) => { - request({ - url: "https://connect.stripe.com/oauth/token", - method: "POST", - json: true, - body: - {client_secret:config.stripe_test_secret_key,code:req.body.stripe, - grant_type:'authorization_code'} - }, (error, response, body) => { - if(body.error){ - return res.status('400').json({ - error: body.error_description - }) - } - req.body.stripe_seller = body - next() - }) -} -``` - -对 Stripe 的 POST API 调用使用平台的密钥和检索到的身份验证代码来完成授权,并返回连接帐户的凭据,然后将其附加到请求正文中,以便用户可以通过`next()`方法进行更新。 - -使用这些凭据,应用可以代表卖家在客户信用卡上创建费用。 - -# 用于结帐的条纹卡元素 - -在结账过程中,为了向用户收集信用卡详细信息,我们将使用 Stripe 的`Card``Elements`在结账表单中添加信用卡字段。为了将`Card``Elements`与我们的 React 接口集成,我们将使用`react-stripe-elements`npm 模块: - -```jsx -npm install --save react-stripe-elements -``` - -我们还需要在`template.js`中注入`Stripe.js`代码来访问前端代码中的条带: - -```jsx - -``` - -对于 MERN Marketplace,`Checkout`组件需要在购物车视图中显示`Card``Elements`和流程卡详细信息输入,只需要在购物车视图中显示条带。因此,我们将在`Cart`组件装入其`componentDidMount`后,使用应用的条带 API 密钥初始化条带实例。 - -`mern-marketplace/client/cart/Cart.js`: - -```jsx -componentDidMount = () => { - if (window.Stripe) { - this.setState({stripe: - window.Stripe(config.stripe_test_api_key)}) - } else { - document.querySelector('#stripe-js') - .addEventListener('load', () - => { - this.setState({stripe: - window.Stripe(config.stripe_test_api_key)}) - }) - } - } -``` - -`Cart.js`中增加的`Checkout`组件需要用`react-stripe-elements`中的`StripeProvider`组件进行包装,这样`Checkout`中的`Elements`就可以访问 Stripe 实例。 - -`mern-marketplace/client/cart/Cart.js`: - -```jsx - - - -``` - -然后,在`Checkout`组件中,我们将使用 Stripe 的`Elements`组件。使用 Stripe 的`Card Elements`将使应用能够收集用户的信用卡详细信息,并使用 Stripe 实例标记卡信息,而不是在我们自己的服务器上处理。在*结账*和*创建新订单*部分将讨论在结账过程中收集卡详细信息和生成卡令牌这一部分的实现。 - -# 为客户记录卡的详细信息 - -在结账过程结束时下订单时,生成的卡令牌将用于创建或更新条带客户([https://stripe.com/docs/api#customers](https://stripe.com/docs/api#customers) 代表我们的用户,这是存储信用卡信息的好方法([https://stripe.com/docs/saving-cards](https://stripe.com/docs/saving-cards) 使用 Stripe 进一步使用,例如仅当卖家从其店铺处理订购的产品时,才为购物车中的特定产品创建费用。这消除了必须在您自己的服务器上安全存储用户信用卡详细信息的复杂性。 - -# 更新用户模型 - -为了跟踪数据库中用户的相应条带`Customer`信息,我们将使用以下字段更新用户模型: - -```jsx -stripe_customer: {}, -``` - -# 更新用户控制器 - -当用户在输入信用卡详细信息后下订单时,我们将创建一个新的或更新现有的条带客户。为了实现这一点,我们将使用`stripeCustomer`方法更新用户控制器,当我们的服务器接收到对创建订单 API 的请求时,将在创建订单之前调用该方法(在*创建新订单*一节中讨论)。 - -在`stripeCustomer`控制器方法中,我们需要使用`stripe`npm 模块: - -```jsx -npm install stripe --save -``` - -安装`stripe`模块后,需要导入到用户控制器文件中,并使用应用的条带密钥初始化`stripe`实例。 - -`mern-marketplace/server/controllers/user.controller.js`: - -```jsx -import stripe from 'stripe' -const myStripe = stripe(config.stripe_test_secret_key) -``` - -`stripeCustomer`控制器方法将首先检查当前用户是否已在数据库中存储了相应的条带客户,然后使用从前端接收的卡令牌创建新的条带客户或更新现有条带客户。 - -# 创建新客户 - -如果当前用户没有对应的条带`Customer`,换句话说,`stripe_customer`字段没有存储值,我们将使用创建客户 API([https://stripe.com/docs/api#create_customer](https://stripe.com/docs/api#create_customer) )来自条纹。 - -`mern-marketplace/server/controllers/user.controller.js`: - -```jsx -myStripe.customers.create({ - email: req.profile.email, - source: req.body.token - }).then((customer) => { - User.update({'_id':req.profile._id}, - {'$set': { 'stripe_customer': customer.id }}, - (err, order) => { - if (err) { - return res.status(400).send({ - error: errorHandler.getErrorMessage(err) - }) - } - req.body.order.payment_id = customer.id - next() - }) -}) -``` - -如果成功创建条带客户,我们将通过在`stripe_customer`字段中存储条带客户 ID 引用来更新当前用户的数据。我们还将把这个客户 ID 添加到下订单中,因此创建与订单相关的费用更简单。 - -# 更新现有客户 - -对于现有的条带客户,换句话说,当前用户在`stripe_customer`字段中存储了一个值,我们将使用条带 API 来更新条带客户。 - -`mern-marketplace/server/controllers/user.controller.js`: - -```jsx - myStripe.customers.update(req.profile.stripe_customer, { - source: req.body.token - }, - (err, customer) => { - if(err){ - return res.status(400).send({ - error: "Could not update charge details" - }) - } - req.body.order.payment_id = customer.id - next() - }) -``` - -成功更新条带客户后,我们将向`next()`调用中创建的订单添加客户 ID。 - -虽然这里没有介绍,但 Stripe Customer 功能可以进一步用于允许用户从应用存储和更新其信用卡信息。 - -# 为处理的每个产品创建费用 - -当卖家通过处理在其店铺订购的产品来更新订单时,应用将代表卖家在客户的信用卡上为订购的产品的成本创建费用。为了实现这一点,我们将使用`createCharge`控制器方法更新`user.controller.js`文件,该方法将使用 Stripe 创建收费 API,并且需要卖方的 Stripe 帐户 ID 和买方的 Stripe 客户 ID。 - -`mern-marketplace/server/controllers/user.controller.js`: - -```jsx -const createCharge = (req, res, next) => { - if(!req.profile.stripe_seller){ - return res.status('400').json({ - error: "Please connect your Stripe account" - }) - } - myStripe.tokens.create({ - customer: req.order.payment_id, - }, { - stripe_account: req.profile.stripe_seller.stripe_user_id, - }).then((token) => { - myStripe.charges.create({ - amount: req.body.amount * 100, //amount in cents - currency: "usd", - source: token.id, - }, { - stripe_account: req.profile.stripe_seller.stripe_user_id, - }).then((charge) => { - next() - }) - }) -} -``` - -如果卖方尚未连接其条带帐户,`createCharge`方法将返回 400 错误响应,表明需要连接条带帐户。 - -为了能够代表卖方的条带帐户向条带客户收费,我们首先需要使用客户 ID 和卖方的条带帐户 ID 生成条带令牌,然后使用该令牌创建收费。 - -当服务器接收到更新订单的请求,且产品状态更改为**处理**时,将调用`createCharge`控制器方法(此订单更新请求的 API 实现将在*车间订单*部分讨论)。 - -这涵盖了与 MERN Marketplace 特定用例的支付处理实现相关的所有条带相关概念。现在我们将继续讨论允许用户完成结账并下订单。 - -# 结账 - -已登录并已将项目添加到购物车的用户将能够启动签出过程。结帐表单将收集客户详细信息、送货地址信息和信用卡信息: - -![](img/82230cf9-ee29-41e7-835e-b9c00f4f5a63.png) - -# 正在初始化签出详细信息 - -在`Checkout`组件中,我们将在从表单收集详细信息之前初始化状态为的`checkoutDetails`对象。 - -`mern-marketplace/client/cart/Checkout.js`: - -```jsx -state = { - checkoutDetails: {customer_name: '', customer_email:'', - delivery_address: {street: '', city: '', state: - '', zipcode: '', country:''}}, - } -``` - -安装组件后,我们将根据当前用户的详细信息预填充客户详细信息,并将当前购物车项目添加到`checkoutDetails` - -`mern-marketplace/client/cart/Checkout.js`: - -```jsx -componentDidMount = () => { - let user = auth.isAuthenticated().user - let checkoutDetails = this.state.checkoutDetails - checkoutDetails.products = cart.getCart() - checkoutDetails.customer_name = user.name - checkoutDetails.customer_email = user.email - this.setState({checkoutDetails: checkoutDetails}) -} -``` - -# 客户信息 - -在结帐表单中,我们将添加文本字段以收集客户姓名和电子邮件。 - -`mern-marketplace/client/cart/Checkout.js`: - -```jsx - -
- -``` - -当用户更新值时,`handleCustomerChange`方法将更新状态中的相关细节: - -```jsx -handleCustomerChange = name => event => { - let checkoutDetails = this.state.checkoutDetails - checkoutDetails[name] = event.target.value || undefined - this.setState({checkoutDetails: checkoutDetails}) -} -``` - -# 送货地址 - -要从用户处收集送货地址,我们将在结帐表单中添加以下文本字段,以收集街道地址、城市、邮政编码、州和国家。 - -`mern-marketplace/client/cart/Checkout.js`: - -```jsx - - - - - -``` - -当用户更新这些地址字段时,`handleAddressChange`方法将更新状态中的相关详细信息。 - -`mern-marketplace/client/cart/Checkout.js`: - -```jsx -handleAddressChange = name => event => { - let checkoutDetails = this.state.checkoutDetails - checkoutDetails.delivery_address[name] = event.target.value || - undefined - this.setState({checkoutDetails: checkoutDetails}) -} -``` - -# PlaceOrder 组件 - -信用卡字段将使用`react-stripe-elements`中的 Stripe`CardElement`组件添加到结帐表单中 - -`CardElement`组件必须是使用`injectStripe`**高阶组件**(**HOC**构建并用`Elements`组件包装的支付表单组件的一部分。因此,我们将创建一个名为`PlaceOrder`的组件,其中包含`injectStripe`,它将包含 Stripe 的`CardElement`和`PlaceOrder`按钮。 - -`mern-marketplace/client/cart/PlaceOrder.js`: - -```jsx -class PlaceOrder extends Component { … } -export default injectStripe(withStyles(styles)(PlaceOrder)) -``` - -然后我们将这个`PlaceOrder`组件添加到签出表单中,将`checkoutDetails`对象作为道具传递给它,并用`react-stripe-elements`中的`Elements`组件包裹它。 - -`mern-marketplace/client/cart/Checkout.js`: - -```jsx - -``` - -`injectStripe`HOC 提供管理`Elements`组的`this.props.stripe`属性。这将允许我们在`PlaceOrder`内致电`this.props.stripe.createToken`向 Stripe 提交卡详细信息并取回卡令牌。 - -# 条纹元件 - -Stripe 的`CardElement`是独立的,所以我们只需将其添加到`PlaceOrder`组件中,然后根据需要添加样式,就可以完成卡片细节的输入。 - -`mern-marketplace/client/cart/PlaceOrder.js`: - -```jsx - -``` - -# 下订单 - -下订单按钮也放置在`CardElement`之后的`PlaceOrder`组件中。 - -`mern-marketplace/client/cart/PlaceOrder.js`: - -```jsx - -``` - -点击 Place Order 按钮将调用`placeOrder`方法,该方法将尝试使用`stripe.createToken`标记卡的详细信息。如果失败,将通知用户错误,但如果成功,则签出详细信息和生成的卡令牌将发送到服务器的创建订单 API(将在下一节中介绍)。 - -`mern-marketplace/client/cart/PlaceOrder.js`: - -```jsx -placeOrder = ()=>{ - this.props.stripe.createToken().then(payload => { - if(payload.error){ - this.setState({error: payload.error.message}) - }else{ - const jwt = auth.isAuthenticated() - create({userId:jwt.user._id}, { - t: jwt.token - }, this.props.checkoutDetails, payload.token.id).then((data) => - { - if (data.error) { - this.setState({error: data.error}) - } else { - cart.emptyCart(()=> { - this.setState({'orderId':data._id,'redirect': true}) - }) - } - }) - } - }) -} -``` - -`client/order/api-order.js`中定义了向后端的创建订单 API 发出 POST 请求的`create`获取方法。它将签出详细信息、卡令牌和用户凭据作为参数,并将其发送到位于`/api/orders/:userId`的 API。 - -`mern-marketplace/client/order/api-order.js`: - -```jsx -const create = (params, credentials, order, token) => { - return fetch('/api/orders/'+params.userId, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify({order: order, token:token}) - }) - .then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 空车 - -如果创建订单 API 成功,我们将使用`cart-helper.js`中的`emptyCart`助手方法清空购物车。 - -`mern-marketplace/client/cart/cart-helper.js`: - -```jsx -emptyCart(cb) { - if(typeof window !== "undefined"){ - localStorage.removeItem('cart') - cb() - } -} -``` - -`emptyCart`方法从`localStorage`中删除 cart 对象,并通过执行传递的回调更新视图的状态。 - -# 重定向到订单视图 - -在下单且购物车清空后,用户将被重定向到订单视图,该视图将向他们显示刚刚下单的详细信息。 - -`mern-marketplace/client/cart/PlaceOrder.js`: - -```jsx -if (this.state.redirect) { - return () -} -``` - -这将表明签出过程已经完成,成功调用了 createorderapi,我们将在服务器中设置该 API,以便在数据库中创建和存储订单。 - -# 创建新秩序 - -当用户下订单时,结帐时确认的订单的详细信息将用于在数据库中创建新的订单记录,更新或为用户创建条带客户,以及减少订购产品的库存量。 - -# 订单模型 - -为了存储订单,我们将为订单模型定义一个 Mongoose 模式,该模式将记录客户详细信息以及用户帐户引用、交货地址信息、付款引用、在时间戳创建和更新,以及一个订购产品数组,其中每个产品的结构将在一个单独的子模式中定义,该子模式称为`CartItemSchema`。 - -# 由客户和为客户订购 - -为了记录订单所针对的客户的详细信息,我们将在`Order`模式中添加`customer_name`和`customer_email`字段。 - -`mern-marketplace/server/models/order.model.js`: - -```jsx -customer_name: { type: String, trim: true, required: 'Name is required' }, -customer_email: { type: String, trim: true, - match: [/.+\@.+\..+/, 'Please fill a valid email address'], - required: 'Email is required' } -``` - -为了引用下订单的登录用户,我们将添加一个`ordered_by`字段。 - -`mern-marketplace/server/models/order.model.js`: - -```jsx -ordered_by: {type: mongoose.Schema.ObjectId, ref: 'User'} -``` - -# 送货地址 - -订单的交货地址信息将存储在交货地址子文档中,包含`street`、`city`、`state`、`zipcode`和`country`字段。 - -`mern-marketplace/server/models/order.model.js`: - -```jsx -delivery_address: { - street: {type: String, required: 'Street is required'}, - city: {type: String, required: 'City is required'}, - state: {type: String}, - zipcode: {type: String, required: 'Zip Code is required'}, - country: {type: String, required: 'Country is required'} - }, -``` - -# 付款参考 - -当订单更新时,付款信息将是相关的,并且在卖方处理订单产品后需要创建费用。我们将在`Order`模式的`payment_id`字段中记录与信用卡详细信息相关的客户 ID。 - -`mern-marketplace/server/models/order.model.js`: - -```jsx -payment_id: {}, -``` - -# 订购的产品 - -订单的主要内容将是订购的产品清单以及详细信息,如每种产品的数量。我们将在`Order`模式中名为`products`的字段中记录此列表。每个产品的结构将在`CartItemSchema`中单独定义。 - -`mern-marketplace/server/models/order.model.js`: - -```jsx -products: [CartItemSchema], -``` - -# CartItem 模式 - -`CartItem`模式将表示订购的每个产品。它将包含对产品的引用、用户订购的产品数量、对产品所属商店的引用以及状态。 - -`mern-marketplace/server/models/order.model.js`: - -```jsx -const CartItemSchema = new mongoose.Schema({ - product: {type: mongoose.Schema.ObjectId, ref: 'Product'}, - quantity: Number, - shop: {type: mongoose.Schema.ObjectId, ref: 'Shop'}, - status: {type: String, - default: 'Not processed', - enum: ['Not processed' , 'Processing', 'Shipped', 'Delivered', - 'Cancelled']} -}) -const CartItem = mongoose.model('CartItem', CartItemSchema) -``` - -产品的`status`只能具有枚举中定义的值,表示卖方更新的订购产品的当前状态。 - -此处定义的`Order`模式将记录客户和卖方完成订购产品的采购步骤所需的详细信息。 - -# 创建订单 API - -创建订单 API 路由在`server/routes/order.routes.js`中声明。订单路线与用户路线非常相似。要在 Express 应用中加载订单路由,我们需要在`express.js`中装载路由,就像我们对身份验证和用户路由所做的那样。 - -`mern-marketplace/server/express.js`: - -```jsx -app.use('/', orderRoutes) -``` - -当创建订单 API 在`/api/orders/:userId`接收到 POST 请求时,将按以下顺序执行许多操作: - -* 确保用户已登录 -* 使用前面讨论的`stripeCustomer`用户控制器方法创建或更新条带`Customer` -* 使用`decreaseQuanity`产品控制器方法更新所有订购产品的库存量 -* 订单是通过`create`订单控制器方法在订单集合中创建的 - -路线定义如下。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.route('/api/orders/:userId') - .post(authCtrl.requireSignin, userCtrl.stripeCustomer, - productCtrl.decreaseQuantity, orderCtrl.create) -``` - -为了检索路由中与`:userId`参数相关联的用户,我们将使用`userByID`用户控制器方法,该方法从用户集合中获取用户,并将其附加到下一个方法访问的请求对象。我们将添加它与订单路线如下。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.param('userId', userCtrl.userByID) -``` - -# 减少产品库存量 - -我们将更新产品控制器文件以添加`decreaseQuantity`控制器方法,该方法将更新新订单中购买的所有产品的库存数量。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const decreaseQuantity = (req, res, next) => { - let bulkOps = req.body.order.products.map((item) => { - return { - "updateOne": { - "filter": { "_id": item.product._id } , - "update": { "$inc": {"quantity": -item.quantity} } - } - } - }) - Product.bulkWrite(bulkOps, {}, (err, products) => { - if(err){ - return res.status(400).json({ - error: "Could not update product" - }) - } - next() - }) -} -``` - -由于本例中的更新操作涉及到集合中的多个产品在与订购的产品数组匹配后的批量更新,我们将使用 MongoDB 中的`bulkWrite`方法,通过一条命令向 MongoDB 服务器发送多个`updateOne`操作。首先使用`map`功能在`bulkOps`中列出所需的多个`updateOne`操作。这将比发送多个独立的保存或更新操作更快,因为使用`bulkWrite()`到 MongoDB 只有一次往返。 - -# 创建订单控制器方法 - -订单控制器中定义的`create`控制器方法获取订单详细信息,创建新订单,并将其保存到 MongoDB 中的订单集合。 - -`mern-marketplace/server/controllers/order.controller.js`: - -```jsx -const create = (req, res) => { - req.body.order.user = req.profile - const order = new Order(req.body.order) - order.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.status(200).json(result) - }) -} -``` - -通过实现此功能,MERN Marketplace 上的任何登录用户都可以在后端创建和存储订单。现在,我们可以设置 API 来按用户、按商店获取订单列表,或者读取单个订单并将获取的数据显示在前端的视图中。 - -# 按店铺订购 - -市场的一个重要功能是允许卖家查看和更新他们在商店收到的产品订单的状态。为了实现这一点,我们将首先设置 API 以按店铺列出订单,然后在卖家更改购买产品的状态时更新订单。 - -# 按店铺空气污染指数列出 - -我们将实现一个 API 来获取特定店铺的订单,这样经过身份验证的卖家就可以查看他们每个店铺的订单。此 API 的请求将在`'/api/orders/shop/:shopId`接收,路径在`order.routes.js`中定义如下。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.route('/api/orders/shop/:shopId') - .get(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.listByShop) -router.param('shopId', shopCtrl.shopByID) -``` - -要检索与路由中的`:shopId`参数关联的店铺,我们将使用`shopByID`店铺控制器方法,该方法从店铺集合中获取店铺,并将其附加到下一个方法访问的请求对象 - -`listByShop`控制器方法将检索购买了具有匹配店铺 ID 的产品的订单,然后填充每个产品的 ID、名称和价格字段,订单按日期从最近到最早排序。 - -`mern-marketplace/server/controllers/order.controller.js`: - -```jsx -const listByShop = (req, res) => { - Order.find({"products.shop": req.shop._id}) - .populate({path: 'products.product', select: '_id name price'}) - .sort('-created') - .exec((err, orders) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(orders) - }) -} -``` - -为了在前端获取此 API,我们将在`api-order.js`中添加相应的`listByShop`方法,用于`ShopOrders`组件中,以显示每个店铺的订单。 - -`mern-marketplace/client/order/api-order.js`: - -```jsx -const listByShop = (params, credentials) => { - return fetch('/api/orders/shop/'+params.shopId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# ShopOrders 组件 - -卖家将在`ShopOrders`组件中查看其订单列表,每个订单仅显示与店铺相关的已购买产品,并允许卖家通过可能的状态值下拉菜单更改产品状态: - -![](img/76b773d7-1bb1-429b-b10b-20933f551b19.png) - -我们将用一个`PrivateRoute`更新`MainRouter`,在`/seller/orders/:shop/:shopId`路线加载`ShopOrders`组件。 - -`mern-marketplace/client/MainRouter.js`: - -```jsx - -``` - -# 列出订单 - -当`ShopOrders`组件挂载时,我们将使用`listByShop`fetch 方法加载相关订单,并将检索到的订单设置为 state。 - -`mern-marketplace/client/order/ShopOrders.js`: - -```jsx - loadOrders = () => { - const jwt = auth.isAuthenticated() - listByShop({ - shopId: this.match.params.shopId - }, {t: jwt.token}).then((data) => { - if (data.error) { - console.log(data) - } else { - this.setState({orders: data}) - } - }) - } -``` - -在视图中,我们将遍历订单列表,并从`Material-UI`将每个订单呈现在可折叠列表中,该列表将在单击时展开。 - -`mern-marketplace/client/order/ShopOrders.js`: - -```jsx - Orders in {this.match.params.shop} - {this.state.orders.map((order, index) => { return - - - - {this.state.open == index ? : } - - - - Deliver to: - - {order.customer_name} ({order.customer_email}) - - - {order.delivery_address.street} - - {order.delivery_address.city}, - {order.delivery_address.state} - {order.delivery_address.zipcode} - - {order.delivery_address.country} - - })} - -``` - -每个扩展订单将显示订单详细信息和`ProductOrderEdit`组件。`ProductOrderEdit`组件将显示购买的产品,并允许卖方编辑每个产品的状态。`updateOrders`方法作为道具传递给`ProductOrderEdit`组件,以便在产品状态更改时更新状态。 - -`mern-marketplace/client/order/ShopOrders.js`: - -```jsx -updateOrders = (index, updatedOrder) => { - let orders = this.state.orders - orders[index] = updatedOrder - this.setState({orders: orders}) -} -``` - -# ProductOrderEdit 组件 - -`ProductOrderEdit`组件将订单对象作为道具,并遍历订单的 products 数组,以仅显示从当前商店购买的产品,同时使用下拉菜单更改每个产品的状态值。 - -`mern-marketplace/client/order/ProductOrderEdit.js`: - -```jsx -{this.props.order.products.map((item, index) => { return - { item.shop == this.props.shopId && - - - - {item.product.name} -

{"Quantity: "+item.quantity}

- }/> - - {this.state.statusValues.map(option => ( - - {option} - - ))} - -
} -``` - -当`ProductOrderEdit`组件加载并设置为`statusValues`中的状态以在下拉列表中呈现为`MenuItem`时,从服务器获取可能的状态值列表。 - -`mern-marketplace/client/order/ProductOrderEdit.js`: - -```jsx -loadStatusValues = () => { - getStatusValues().then((data) => { - if (data.error) { - this.setState({error: "Could not get status"}) - } else { - this.setState({statusValues: data, error: ''}) - } - }) -} -``` - -当从可能的状态值中选择一个选项时,将调用`handleStatusChange`方法更新处于状态的订单,并根据所选状态值向相应的后端 API 发送请求。 - -`mern-marketplace/client/order/ProductOrderEdit.js`: - -```jsx -handleStatusChange = productIndex => event => { - let order = this.props.order - order.products[productIndex].status = event.target.value - let product = order.products[productIndex] - const jwt = auth.isAuthenticated() - if(event.target.value == "Cancelled"){ - cancelProduct({ shopId: this.props.shopId, - productId: product.product._id }, - {t: jwt.token}, - {cartItemId: product._id, status: - event.target.value, - quantity: product.quantity - }).then((data) => { - if (data.error) { - this.setState({error: "Status not updated, - try again"}) - } else { - this.props.updateOrders(this.props.orderIndex, order) this.setState(error: '') - } - }) - } else if(event.target.value == "Processing"){ - processCharge({ userId: jwt.user._id, shopId: - this.props.shopId, orderId: order._id }, - { t: jwt.token}, - { cartItemId: product._id, - amount: (product.quantity * - product.product.price) - status: event.target.value }).then((data) => { ... - }) - } else { - update({ shopId: this.props.shopId }, {t: - jwt.token}, - { cartItemId: product._id, - status: event.target.value}).then((data) => { ... }) - } -} -``` - -`api-order.js`中定义了`cancelProduct`、`processCharge`和`update`取数方法,用于在后端调用相应的 API 来更新已取消产品的库存量,在处理产品时在客户的信用卡上创建费用,以及在产品状态发生变化时更新订单。 - -# 订购产品的原料药 - -允许卖家更新产品的状态需要设置四种不同的 API,包括检索可能的状态值的 API。然后,实际状态更新将需要 API 来处理状态更改时订单本身的更新,启动相关操作,例如增加取消产品的库存量,以及在处理产品时在客户的信用卡上创建费用。 - -# 获取状态值 - -订购产品的可能状态值在`CartItem`模式中设置为枚举,为了在下拉视图中将这些值显示为选项,我们将在`/api/order/status_values`设置获取这些值的 GET API 路由。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.route('/api/order/status_values') - .get(orderCtrl.getStatusValues) -``` - -`getStatusValues`控制器方法将从`CartItem`模式返回`status`字段的枚举值。 - -`mern-marketplace/server/controllers/order.controller.js`: - -```jsx -const getStatusValues = (req, res) => { - res.json(CartItem.schema.path('status').enumValues) -} -``` - -我们还将在`api-order.js`中设置一个`fetch`方法,用于视图中向 API 路由发出请求。 - -`mern-marketplace/client/order/api-order.js`: - -```jsx -const getStatusValues = () => { - return fetch('/api/order/status_values', { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 更新订单状态 - -当产品状态更改为除**处理**和**取消**之外的任何值时,如果当前用户是订购产品的店铺的已验证所有者,则对`'/api/order/status/:shopId'`的 PUT 请求将直接更新数据库中的订单。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.route('/api/order/status/:shopId') - .put(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.update) -``` - -`update`控制器方法将查询订单集合,找到与更新产品匹配的`CartItem`对象的订单,并在订单的`products`数组中设置该匹配的`CartItem`的`status`值。 - -`mern-marketplace/server/controllers/order.controller.js`: - -```jsx -const update = (req, res) => { - Order.update({'products._id':req.body.cartItemId}, {'$set': { - 'products.$.status': req.body.status - }}, (err, order) => { - if (err) { - return res.status(400).send({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(order) - }) -} -``` - -在`api-order.js`中,我们将添加一个`update`fetch 方法,使用从视图传递的所需参数调用此更新 API。 - -`mern-marketplace/client/order/api-order.js`: - -```jsx -const update = (params, credentials, product) => { - return fetch('/api/order/status/' + params.shopId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(product) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 取消产品订单 - -当卖家决定取消某个产品的订单时,会向`/api/order/:shopId/cancel/:productId`发送 PUT 请求,以便增加产品库存量,并在数据库中更新订单。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.route('/api/order/:shopId/cancel/:productId') - .put(authCtrl.requireSignin, shopCtrl.isOwner, - productCtrl.increaseQuantity, orderCtrl.update) - router.param('productId', productCtrl.productByID) -``` - -要检索路由中与`productId`参数关联的产品,我们将使用`productByID`产品控制器方法 - -`product.controller.js`增加了`increaseQuantity`控制器方法。它根据产品集合中的匹配 ID 查找产品,并根据客户订购的数量增加数量值,因为此产品的订单已取消。 - -`mern-marketplace/server/controllers/product.controller.js`: - -```jsx -const increaseQuantity = (req, res, next) => { - Product.findByIdAndUpdate(req.product._id, {$inc: - {"quantity": req.body.quantity}}, {new: true}) - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - next() - }) -} -``` - -从这个角度来看,我们将使用`api-order.js`中增加的相应的取数方法来调用取消产品订单 API。 - -`mern-marketplace/client/order/api-order.js`: - -```jsx -const cancelProduct = (params, credentials, product) => { - return fetch('/api/order/'+params.shopId+'/cancel/'+params.productId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(product) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 产品加工费 - -当卖家将产品状态更改为**处理**时,我们将设置一个后端 API,不仅更新订单,还将在客户的信用卡上为产品价格乘以订购数量创建费用。 - -`mern-marketplace/server/routes/order.routes.js`: - -```jsx -router.route('/api/order/:orderId/charge/:userId/:shopId') - .put(authCtrl.requireSignin, shopCtrl.isOwner, - userCtrl.createCharge, orderCtrl.update) -router.param('orderId', orderCtrl.orderByID) -``` - -为了检索路由中与`orderId`参数关联的订单,我们将使用`orderByID`订单控制器方法,该方法从订单集合中获取订单,并将其附加到`next`方法访问的请求对象,如下所示。 - -`mern-marketplace/server/controllers/order.controller.js:` - -```jsx -const orderByID = (req, res, next, id) => { - Order.findById(id).populate('products.product', 'name price') - .populate('products.shop', 'name') - .exec((err, order) => { - if (err || !order) - return res.status('400').json({ - error: "Order not found" - }) - req.order = order - next() - }) -} -``` - -此过程费用 API 将在`/api/order/:orderId/charge/:userId/:shopId`接收 PUT 请求,在成功验证后,用户将通过调用`createCharge`用户控制器创建费用,如前面的*使用条带支付*部分所述,然后最终使用`update`方法更新订单。 - -从这个角度来看,我们将使用`api-order.js`中的`processCharge`取数方式,并提供所需的路由参数值、凭证和产品详细信息,包括收费金额。 - -`mern-marketplace/client/order/api-order.js`: - -```jsx -const processCharge = (params, credentials, product) => { - return fetch('/api/order/'+params.orderId+'/charge/'+params.userId+'/' - +params.shopId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(product) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -卖家可以查看在每个店铺收到的产品订单,他们可以轻松地更新每个订购产品的状态,同时应用负责其他任务,例如更新库存数量和启动付款。这涵盖了 MERN Marketplace 应用的基本订单管理功能,可以根据需要进一步扩展。 - -# 查看订单详细信息 - -在订单收集和数据库访问都已设置的情况下,向前看,很容易添加为每个用户列出订单的功能,并在单独的视图中显示单个订单的详细信息,用户可以在其中跟踪每个订购产品的状态: - -![](img/4af78ad1-eb61-4c8b-a32b-a56d7ba0b649.png) - -按照本书中重复的步骤,为了设置后端 API 来检索数据,并在前端使用它来构建前端视图,您可以根据需要开发订单相关视图,灵感来自 MERN Marketplace 应用代码中这些示例视图的快照: - -![](img/c1282af6-a2af-41c2-8909-5f176615778d.png) - -在本章[第 6 章](06.html)*中开发的 MERN Marketplace 应用*通过构建 MERN 骨架应用,运用在线市场的新 MERN 技能,涵盖了标准在线市场应用的关键功能。这反过来又说明了如何扩展 MERN 堆栈以合并复杂的功能。 - -# 总结 - -在本章中,我们扩展了 MERN Marketplace 应用,并探讨了如何在在线市场应用中为买家添加购物车、使用信用卡付款的结账流程以及为卖家添加订单管理。 - -我们发现了 MERN stack 技术如何与第三方集成很好地协同工作,因为我们实现了购物车结帐流程,并使用 Stripe 提供的用于管理在线支付的工具处理订购产品的信用卡费用 - -我们还解锁了 MERN 的更多功能,例如 MongoDB 中优化的批量写入操作,用于响应单个 API 调用更新多个文档。这允许我们一次性减少多个产品的库存量,例如当用户从不同的商店订购多个产品时。 - -MERN marketplace 应用中开发的 marketplace 功能揭示了如何通过添加简单或更复杂的功能,利用此堆栈和结构来设计和构建不断增长的应用。 - -在下一章中,我们将学习本书到目前为止所学到的经验教训,并通过扩展 MERN 框架构建媒体流应用,探索 MERN 更高级的可能性。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/08.md b/docs/full-stk-react-proj/08.md deleted file mode 100644 index 835bb7a..0000000 --- a/docs/full-stk-react-proj/08.md +++ /dev/null @@ -1,1014 +0,0 @@ -# 八、构建媒体流应用 - -一段时间以来,上传和流媒体内容,特别是视频内容,已经成为互联网文化中日益增长的一部分。从共享个人视频内容的个人到通过在线流媒体服务传播商业内容的娱乐业,我们都依赖于能够顺利上传和流媒体的 web 应用。MERN 堆栈技术中的功能可用于将这些核心流功能构建并集成到任何基于 MERN 的 web 应用中。 - -在本章中,我们将介绍以下主题,通过扩展 MERN skeleton 应用来实现基本的媒体上传和流媒体: - -* 将视频上载到 MongoDB GridFS -* 存储和检索媒体详细信息 -* 从 GridFS 到基本媒体播放器的流媒体 - -# 梅恩媒体流 - -我们将通过扩展基础应用来构建 MERN Mediastream 应用。这将是一个简单的视频流应用,允许注册用户上传任何浏览该应用的人都可以流的视频: - -![](img/71c4f48b-a483-4c54-aa63-b515ec8ff080.png) - -The code for the complete MERN Mediastream application is available on GitHub [github.com/shamahoque/mern-mediastream](https://github.com/shamahoque/mern-mediastream). The implementations discussed in this chapter can be accessed in the `simple-mediastream-gridfs` branch of the same repository. You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -与媒体上传、编辑、编辑相关的功能所需的视图,将通过扩展和修改 MERN skeleton 应用中现有的 React 组件来开发简单媒体播放器中的流媒体。下图中的组件树显示了构成本章开发的 MERN Mediastream 前端的所有自定义 React 组件: - -![](img/4ff4252c-f5de-4c9d-a967-550090c679eb.jpg) - -# 上传和存储媒体 - -MERN Mediastream 上的注册用户将能够从本地文件上传视频,并使用 GridFS 将视频和相关详细信息直接存储在 MongoDB 上。 - -# 媒体模型 - -为了存储媒体详细信息,我们将在`server/models/media.model.js`中为媒体模型添加一个 Mongoose 模式,其中包含记录媒体标题、描述、类型、视图数量、创建时间、更新时间以及发布媒体的用户参考的字段。 - -`mern-mediastream/server/models/media.model.js`: - -```jsx -import mongoose from 'mongoose' -import crypto from 'crypto' -const MediaSchema = new mongoose.Schema({ - title: { - type: String, - required: 'title is required' - }, - description: String, - genre: String, - views: {type: Number, default: 0}, - postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'}, - created: { - type: Date, - default: Date.now - }, - updated: { - type: Date - } -}) - -export default mongoose.model('Media', MediaSchema) -``` - -# MongoDB GridFS 用于存储大型文件 - -在前面的章节中,我们讨论了如何将用户上传的文件作为二进制数据直接存储在 MongoDB 中。但这只适用于小于 16MB 的文件。为了在 MongoDB 中存储更大的文件,我们需要使用 GridFS。 - -GridFS 在 MongoDB 中存储大型文件,方法是将文件划分为多个最大为 255 KB 的块,然后将每个块存储为单独的文档。当需要检索文件以响应对 GridFS 的查询时,会根据需要重新组合块。这将打开一个选项,根据需要只获取和加载文件的一部分,而不是检索整个文件。 - -在为 MERN Mediastream 存储和检索视频文件的情况下,我们将利用 GridFS 来存储视频文件,并根据用户跳转到哪个部分并从哪个部分开始播放,来流式传输部分视频。 - -我们将使用`gridfs-stream`npm 模块向服务器端代码添加 GridFS 功能: - -```jsx -npm install gridfs-stream --save -``` - -要使用我们的数据库连接配置`gridfs-stream`,我们将使用 Mongoose 将其链接起来,如下所示。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -import mongoose from 'mongoose' -import Grid from 'gridfs-stream' -Grid.mongo = mongoose.mongo -let gridfs = null -mongoose.connection.on('connected', () => { - gridfs = Grid(mongoose.connection.db) -}) -``` - -`gridfs`对象将允许访问 GridFS 功能,这些功能在创建新介质时用于存储文件,在介质流回到用户时用于获取文件。 - -# 创建媒体 API - -我们将在 Express 服务器上设置一个创建媒体 API,该 API 将在`'/api/media/new/:userId'`接收 POST 请求,多部分正文内容包含媒体字段和上传的视频文件。 - -# 创建媒体的路径 - -在`server/routes/media.routes.js`中,我们将添加创建路由,并使用来自用户控制器的`userByID`方法。`userByID`方法处理 URL 中传递的`:userId`参数,并从数据库中检索相关用户。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/media/new/:userId') - .post(authCtrl.requireSignin, mediaCtrl.create) -router.param('userId', userCtrl.userByID) -``` - -对创建路由的 POST 请求将首先确保用户已登录,然后在媒体控制器中启动`create`方法。 - -与用户和身份验证路由类似,我们必须在`express.js`中的 Express 应用上装载媒体路由,如下所示。 - -`mern-mediastream/server/express.js`: - -```jsx -app.use('/', mediaRoutes) -``` - -# 用于处理创建请求的控制器方法 - -媒体控制器中的`create`控制器方法将使用`formidable`npm 模块解析包含用户上传的媒体详情和视频文件的多部分请求主体: - -```jsx -npm install formidable --save -``` - -表单数据中接收到的媒体字段,经过`formidable`解析后,将用于生成新的媒体对象并保存到数据库中。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const create = (req, res, next) => { - let form = new formidable.IncomingForm() - form.keepExtensions = true - form.parse(req, (err, fields, files) => { - if (err) { - return res.status(400).json({ - error: "Video could not be uploaded" - }) - } - let media = new Media(fields) - media.postedBy= req.profile - if(files.video){ - let writestream = gridfs.createWriteStream({_id: media._id}) - fs.createReadStream(files.video.path).pipe(writestream) - } - media.save((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(result) - }) - }) -} -``` - -如果请求中有文件,`formidable`将临时存储在文件系统中,我们将使用媒体对象的 ID 创建一个`gridfs.writeStream`来读取临时文件并将其写入 MongoDB。这将在 MongoDB 中生成相关的区块和文件信息文档。当需要检索此文件时,我们将使用媒体 ID 标识它。 - -# 在视图中获取并创建 API - -在`api-media.js`中,我们将添加一个相应的方法,通过从视图传递多部分表单数据,向 create API 发出`POST`请求。 - -`mern-mediastream/client/user/api-user.js`: - -```jsx -const create = (params, credentials, media) => { - return fetch('/api/media/new/'+ params.userId, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: media - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -当用户提交新媒体表单上传新视频时,将使用此`create`获取方法。 - -# 新媒体形式视图 - -注册用户将在菜单上看到添加新媒体的链接。此链接将带他们进入新媒体表单视图,并允许他们上传视频文件以及视频的详细信息。 - -# 添加媒体菜单按钮 - -在`client/core/Menu.js`中,我们将更新呈现我的个人资料和注销链接的现有代码,以添加添加媒体按钮链接: - -![](img/70473631-4b0c-4671-a6d6-5f5ea13228f7.png) - -仅当用户当前已登录时,此选项才会显示在菜单上。 - -`mern-mediastream/client/core/Menu.js/`: - -```jsx - - - -``` - -# 新媒体视图的反应路线 - -当用户点击添加媒体链接时,为了将用户带到新媒体表单视图,我们将更新`MainRouter`文件以添加`/media/new`反应路径,该路径将呈现`NewMedia`组件。 - -`mern-mediastream/client/MainRouter.js`: - -```jsx - -``` - -由于此新媒体表单只能由登录用户访问,因此我们将其添加为`PrivateRoute`。 - -# 新媒体组件 - -在`NewMedia`组件中,我们将呈现一个表单,允许用户通过输入标题、描述和流派,并从本地文件系统上传视频文件来创建媒体: - -![](img/88d4481d-4f65-41db-9f97-d32ab8547e5e.png) - -我们将使用 Material UI`Button`和 HTML5`file input`元素添加文件上传元素。 - -`mern-mediastream/client/media/NewMedia.js`: - -```jsx - - -{this.state.video ? this.state.video.name : ''} - -``` - -`Title`、`Description`和`Genre`表单字段将添加`TextField`组件。 - -`mern-mediastream/client/media/NewMedia.js`: - -```jsx -
-
-
-``` - -这些表单字段更改将通过`handleChange`方法进行跟踪。 - -`mern-mediastream/client/media/NewMedia.js`: - -```jsx -handleChange = name => event => { - const value = name === 'video' - ? event.target.files[0] - : event.target.value - this.mediaData.set(name, value) - this.setState({ [name]: value }) -} -``` - -`handleChange`方法使用新值更新状态,并填充`mediaData`,这是一个`FormData`对象。`FormData`API 确保发送到服务器的数据以编码类型`multipart/form-data`所需的正确格式存储。此`mediaData`对象在`componentDidMount`中初始化。 - -`mern-mediastream/client/media/NewMedia.js`: - -```jsx -componentDidMount = () => { - this.mediaData = new FormData() -} -``` - -表单提交后,调用具有必要凭据的`create`fetch 方法,并将表单数据作为参数传递: - -```jsx - clickSubmit = () => { - const jwt = auth.isAuthenticated() - create({ - userId: jwt.user._id - }, { - t: jwt.token - }, this.mediaData).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({redirect: true, mediaId: data._id}) - } - }) - } -``` - -在成功创建媒体时,可以根据需要将用户重定向到不同的视图,例如,重定向到具有新媒体详细信息的媒体视图。 - -`mern-mediastream/client/media/NewMedia.js`: - -```jsx -if (this.state.redirect) { - return () -} -``` - -为了允许用户流式传输和查看存储在 MongoDB 中的此视频文件,接下来我们将实现如何在视图中检索和渲染视频。 - -# 检索和流媒体 - -在服务器上,我们将设置检索单个视频文件的路由,然后将其用作 React media player 中的源,以渲染流式视频。 - -# 获取视频 API - -在`'/api/medias/video/:mediaId'`接收到 GET 请求时,我们将在媒体路由中添加一个路由来获取视频。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/medias/video/:mediaId') - .get(mediaCtrl.video) -router.param('mediaId', mediaCtrl.mediaByID) -``` - -路由 URL 中的`:mediaId`参数将在`mediaByID`控制器中处理,从媒体集合中获取相关文档并附加到请求对象,因此可以根据需要在`video`控制器方法中使用。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const mediaByID = (req, res, next, id) => { - Media.findById(id).populate('postedBy', '_id name').exec((err, media) => { - if (err || !media) - return res.status('400').json({ - error: "Media not found" - }) - req.media = media - next() - }) -} -``` - -`media.controller.js`中的`video`控制器方法将使用`gridfs`查找 MongoDB 中与`mediaId`关联的视频。然后,如果找到匹配的视频,并且根据请求是否包含范围标头,响应将发送回正确的视频块,并将相关内容信息设置为响应标头。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const video = (req, res) => { - gridfs.findOne({ - _id: req.media._id - }, (err, file) => { - if (err) { - return res.status(400).send({ - error: errorHandler.getErrorMessage(err) - }) - } - if (!file) { - return res.status(404).send({ - error: 'No video found' - }) - } - - if (req.headers['range']) { - ... - ... consider range headers and send only relevant chunks in - response ... - ... - } else { - res.header('Content-Length', file.length) - res.header('Content-Type', file.contentType) - - gridfs.createReadStream({ - _id: file._id - }).pipe(res) - } - }) -} -``` - -4 如果请求包含范围标头,例如当用户拖动到视频中间并从该点开始播放时,我们需要将范围标头转换为开始和结束位置,这些位置将与使用 GridFS 存储的正确区块相对应。然后,我们将把这些开始值和结束值作为一个范围传递给 gridfs 流的`createReadStream`方法,并使用其他文件详细信息(包括内容长度、范围和类型)设置响应头。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -let parts = req.headers['range'].replace(/bytes=/, "").split("-") -let partialstart = parts[0] -let partialend = parts[1] - -let start = parseInt(partialstart, 10) -let end = partialend ? parseInt(partialend, 10) : file.length - 1 -let chunksize = (end - start) + 1 - -res.writeHead(206, { - 'Accept-Ranges': 'bytes', - 'Content-Length': chunksize, - 'Content-Range': 'bytes ' + start + '-' + end + '/' + file.length, - 'Content-Type': file.contentType -}) - -gridfs.createReadStream({ - _id: file._id, - range: { - startPos: start, - endPos: end - } -}).pipe(res) -``` - -连接到响应的最终`readStream`可以直接在基本 HTML5 媒体播放器或前端视图中的 React 风格媒体播放器中呈现。 - -# 使用媒体播放器渲染视频 - -React 风味媒体播放器的一个好选择是作为 npm 提供的`ReactPlayer`组件,可根据需要定制: - -![](img/815895de-c19d-439e-b16b-56eb8b0c12d2.png) - -可通过安装相应的`npm`模块在应用中使用: - -```jsx -npm install react-player --save -``` - -对于浏览器提供的默认控件的基本用法,我们可以将其添加到应用中任何可以访问要呈现的媒体 ID 的 React 视图中: - -```jsx - -``` - -在下一章中,我们将研究使用我们自己的控件定制此`ReactPlayer`的高级选项。 - -To learn more about what is possible with `ReactPlayer`, visit [cookpete.com/react-player](https://cookpete.com/react-player). - -# 媒体列表 - -在 MERN Mediastream 中,我们将添加相关媒体的列表视图以及每个视频的快照,以便访问者更方便地访问和浏览应用上的视频。我们将在后端设置列表 API,以检索不同的列表,例如单个用户上传的视频和应用中具有最高视图的最流行视频。然后,这些检索到的列表可以在`MediaList`组件中呈现,该组件将从获取特定 API 的父组件接收列表作为道具: - -![](img/6c2da7fb-dec3-407d-b2e4-3114b4b61e71.png) - -在前面的屏幕截图中,`Profile`组件使用用户列表 API 获取用户在前面的配置文件中发布的媒体列表,并将接收到的列表传递给`MediaList`组件以呈现每个视频和媒体细节。 - -# 媒体列表组件 - -`MediaList`组件是一个可重用的组件,它将获取一个媒体列表并在其中迭代以呈现视图中的每个项目。在 MERN Mediastream 中,我们使用它来呈现主视图中最流行的媒体列表以及特定用户在其个人资料中上载的媒体列表。 - -`mern-mediastream/client/media/MediaList.js`: - -```jsx - - {this.props.media.map((tile, i) => ( - - - - - {tile.title}} - subtitle={{tile.views} views - {tile.genre}}/> - - ))} - -``` - -`MediaList`组件在遍历道具中发送的列表时使用 Material UI`GridList`组件,并呈现列表中每个项目的媒体详细信息,以及呈现视频 URL 而不显示任何控件的`ReactPlayer`组件。从这个角度来看,这让访问者对媒体有了一个简单的概述,同时也对视频内容有了一个粗略的了解。 - -# 列出流行媒体 - -为了从数据库中检索特定的媒体列表,我们需要在服务器上设置相关的 API。对于大众媒体,我们将在`/api/media/popular`设置接收 GET 请求的路由。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx - router.route('/api/media/popular') - .get(mediaCtrl.listPopular) -``` - -`listPopular`控制器方法将查询媒体集合,以检索整个集合中`views`最高的十个媒体文档。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const listPopular = (req, res) => { - Media.find({}).limit(10) - .populate('postedBy', '_id name') - .sort('-views') - .exec((err, posts) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(posts) - }) -} -``` - -要在视图中使用此 API,我们将在`api-media.js`中设置相应的获取方法。 - -`mern-mediastream/client/media/api-media.js`: - -```jsx -const listPopular = (params) => { - return fetch('/api/media/popular', { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -当`Home`组件挂载时,将调用此`fetch`方法,以便将列表设置为状态并传递给视图中的`MediaList`组件。 - -`mern-mediastream/client/core/Home.js`: - -```jsx -componentDidMount = () => { - listPopular().then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({media: data}) - } - }) - } -``` - -在主视图中,我们将添加`MediaList`如下,列表作为道具提供: - -```jsx - -``` - -# 按用户列出媒体 - -为了检索特定用户上传的媒体列表,我们将设置一个 API,该 API 的路由在`'/api/media/by/:userId'`处接受 GET 请求。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/media/by/:userId') - .get(mediaCtrl.listByUser) -``` - -`listByUser`控制器方法将查询媒体集合,查找`postedBy`值与`userId`匹配的媒体文档。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const listByUser = (req, res) => { - Media.find({postedBy: req.profile._id}) - .populate('postedBy', '_id name') - .sort('-created') - .exec((err, posts) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(posts) - }) -} -``` - -要在前端视图中通过用户 API 使用此列表,我们将在`api-media.js`中设置相应的`fetch`方法。 - -`mern-mediastream/client/user/api-user.js`: - -```jsx -const listByUser = (params) => { - return fetch('/api/media/by/'+ params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -此获取方法可在`Profile`组件中使用,类似于主视图中使用的`listPopular`获取方法,用于检索列表数据,设置为状态,然后传递给`MediaList`组件。 - -# 显示、更新和删除介质 - -MERN Mediastream 的任何访问者都将能够查看媒体详细信息并流式播放视频,而只有注册用户才能在将媒体发布到应用后随时编辑详细信息并删除媒体。 - -# 显示媒体 - -MERN Mediastream 的任何访问者都可以浏览到单个媒体视图来播放视频并阅读与媒体相关的详细信息。每次在应用上加载特定视频时,我们也会增加与媒体相关联的视图数。 - -# 读取媒体 API - -为了获取特定媒体记录的媒体信息,我们将在`'/api/media/:mediaId'`处设置一个接受 GET 请求的路由。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/media/:mediaId') - .get( mediaCtrl.incrementViews, mediaCtrl.read) -``` - -请求 URL 中的`mediaId`将导致`mediaByID`控制器方法执行并将检索到的媒体文档附加到请求对象。然后该媒体数据将通过`read`控制器方法在响应中返回。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const read = (req, res) => { - return res.json(req.media) -} -``` - -对该 API 的 GET 请求也将执行`incrementViews`控制器方法,该方法将找到匹配的媒体记录,并在将更新的记录保存到数据库之前将`views`值增加`1`。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const incrementViews = (req, res, next) => { - Media.findByIdAndUpdate(req.media._id, {$inc: {"views": 1}}, {new: true}) - .exec((err, result) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - next() - }) -} -``` - -为了在前端使用此读取 API,我们将在`api-media.js`中设置相应的获取方法。 - -`mern-mediastream/client/user/api-user.js`: - -```jsx -const read = (params) => { - return fetch(config.serverUrl+'/api/media/' + params.mediaId, { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -read API 可用于在视图中呈现单个媒体详细信息或预填充媒体编辑表单。 - -# 媒体组件 - -`Media`组件将呈现单个媒体记录的详细信息,并以带有默认浏览器控件的基本`ReactPlayer`格式传输视频: - -![](img/0573b444-addd-489c-99eb-7fb12e3c0a68.png) - -`Media`组件可以调用读取 API 来获取媒体数据本身,或者作为道具从调用读取 API 的父组件接收数据。在后一种情况下,父组件将添加`Media`组件,如下所示。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx - -``` - -在 MERN Mediastream 中,我们将`Media`组件添加到`PlayMedia`组件中,该组件使用读取 API 从服务器获取媒体内容,并将其作为道具传递给媒体。`Media`组件将获取该数据并在视图中渲染,以显示细节并将视频加载到`ReactPlayer`组件中 - -标题、类型和视图计数可以在材质 UI`CardHeader`组件中呈现。 - -`mern-mediastream/client/media/Media.js`: - -```jsx - - {this.props.media.views + ' views'} -
} - subheader={this.props.media.genre} -/> -``` - -视频 URL 基本上是我们在后端设置的 GETAPI 路由,加载到带有默认浏览器控件的`ReactPlayer`中。 - -`mern-mediastream/client/media/Media.js`: - -```jsx -const mediaUrl = this.props.media._id - ? `/api/media/video/${this.props.media._id}` - : null - … - -``` - -`Media`组件提供有关发布视频的用户、媒体描述以及媒体创建日期的其他详细信息。 - -`mern-mediastream/client/media/Media.js`: - -```jsx - - - - {this.props.media.postedBy.name && - this.props.media.postedBy.name[0]} - - - - - - - -``` - -如果当前登录的用户也是发布所显示媒体的用户,`Media`组件也有条件地显示编辑和删除选项。 - -`mern-mediastream/client/media/Media.js`: - -```jsx -{(auth.isAuthenticated().user && auth.isAuthenticated().user._id) - == this.props.media.postedBy._id && ( - - - - - - - )} -``` - -“编辑”选项链接到“媒体编辑”窗体,“删除”选项打开一个对话框,可以启动从数据库中删除此特定媒体文档的操作。 - -# 更新媒体详细信息 - -注册用户将有权访问其每次媒体上载的编辑表单,更新和提交此表单将在媒体集合中保存对文档的更改。 - -# 媒体更新 API - -为了允许用户更新媒体详细信息,我们将设置一个媒体更新 API,该 API 在`'/api/media/:mediaId'`接受 PUT 请求,并在请求正文中包含更新的详细信息。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/media/:mediaId') - .put(authCtrl.requireSignin, - mediaCtrl.isPoster, - mediaCtrl.update) -``` - -当收到此请求时,服务器将首先通过调用`isPoster`控制器方法确保登录用户是媒体内容的原始海报。 - -`mern-mediastream/server/controllers/media.controller.js:` - -```jsx -const isPoster = (req, res, next) => { - let isPoster = req.media && req.auth - && req.media.postedBy._id == req.auth._id - if(!isPoster){ - return res.status('403').json({ - error: "User is not authorized" - }) - } - next() -} -``` - -如果用户获得授权,`update`控制器方法将被调用`next`,用更改更新现有媒体文档,然后将其保存到数据库中。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const update = (req, res, next) => { - let media = req.media - media = _.extend(media, req.body) - media.updated = Date.now() - media.save((err) => { - if (err) { - return res.status(400).send({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(media) - }) -} -``` - -为了访问前端的更新 API,我们将在`api-media.js`中添加相应的获取方法,该方法以必要的凭证和媒体详细信息为参数。 - -`mern-mediastream/client/user/api-user.js`: - -```jsx -const update = (params, credentials, media) => { - return fetch('/api/media/' + params.mediaId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(media) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -当用户进行更新并提交表单时,将在媒体编辑表单中使用此获取方法。 - -# 媒体编辑表 - -媒体编辑表单与新媒体表单类似,但没有上载选项,字段将预先填充现有详细信息: - -![](img/dd5cc45f-20f1-422b-b493-2d38f7343bca.png) - -包含此表单的`EditMedia`组件将在`'/media/edit/:mediaId'`处呈现,该组件只能由登录用户访问。此专用路线将在`MainRouter`中与其他前端路线一起申报。 - -`mern-mediastream/client/MainRouter.js`: - -```jsx - -``` - -一旦`EditMedia`组件挂载到视图上,将对读取媒体 API 进行获取调用,以检索媒体详细信息并设置为状态,以便在文本字段中呈现值。 - -`mern-mediastream/client/media/EditMedia.js`: - -```jsx - componentDidMount = () => { - read({mediaId: this.match.params.mediaId}).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({media: data}) - } - }) - } -``` - -表单字段元素将与`NewMedia`组件中的相同。当用户更新表单中的任何值时,更改将通过调用`handleChange`方法注册到状态为的`media`对象中。 - -`mediastream/client/media/EditMedia.js`: - -```jsx -handleChange = name => event => { - let updatedMedia = this.state.media - updatedMedia[name] = event.target.value - this.setState({media: updatedMedia}) -} -``` - -当用户完成编辑并单击提交时,将使用所需凭据和更改的媒体值调用更新 API。 - -`mediastream/client/media/EditMedia.js`: - -```jsx - clickSubmit = () => { - const jwt = auth.isAuthenticated() - update({ - mediaId: this.state.media._id - }, { - t: jwt.token - }, this.state.media).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({error: '', redirect: true, media: data}) - } - }) -} -``` - -这将更新媒体详细信息,与媒体关联的视频文件将保持数据库中的原样。 - -# 删除媒体 - -经过身份验证的用户可以完全删除他们上载到应用的媒体,包括媒体集合中的媒体文档,以及使用 GridFS 存储在 MongoDB 中的文件块。 - -# 删除媒体 API - -在后端,我们将添加一个删除路由,允许授权用户删除他们上传的媒体记录。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/media/:mediaId') - .delete(authCtrl.requireSignin, - mediaCtrl.isPoster, - mediaCtrl.remove) -``` - -当服务器在`'/api/media/:mediaId'`收到删除请求时,首先确认登录用户是需要删除的媒体的原始海报。然后`remove`控制器方法将从数据库中删除指定的媒体详细信息。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const remove = (req, res, next) => { - let media = req.media - media.remove((err, deletedMedia) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - gridfs.remove({ _id: req.media._id }) - res.json(deletedMedia) - }) -} -``` - -除了从媒体集合中删除媒体记录外,我们还使用`gridfs`删除数据库中存储的相关文件详细信息和区块。 - -我们还将在`api-media.js`中添加相应的方法,从视图中获取`delete`API。 - -`mern-mediastream/client/user/api-user.js`: - -```jsx -const remove = (params, credentials) => { - return fetch('/api/media/' + params.mediaId, { - method: 'DELETE', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 删除媒体组件 - -`DeleteMedia`组件被添加到`Media`组件中,并且仅对添加此特定媒体的登录用户可见。此组件将媒体 ID 和标题作为道具: - -![](img/b0470db1-6793-4723-8c9e-b918a4929e74.png) - -这个`DeleteMedia`组件基本上是一个图标按钮,点击它会打开一个确认对话框,询问用户是否确定要删除视频。 - -`mern-mediastream/client/media/DeleteMedia.js`: - -```jsx - - - - - {"Delete "+this.props.mediaTitle} - - - Confirm to delete {this.props.mediaTitle} from your account. - - - - - - - -``` - -当用户确认删除意图时,调用`delete`fetch 方法。 - -`mern-mediastream/client/media/DeleteMedia.js`: - -```jsx -deleteMedia = () => { - const jwt = auth.isAuthenticated() - remove({ - mediaId: this.props.mediaId - }, {t: jwt.token}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({redirect: true}) - } - }) -} -``` - -成功删除后,用户将重定向到主页。 - -`mern-mediastream/client/media/DeleteMedia.js`: - -```jsx -if (this.state.redirect) { - return -} -``` - -本章中开发的 MERN Mediastream 应用是一个完整的媒体流应用,具有将视频文件上载到数据库、将存储的视频流回观众、支持 CRUD 操作(如媒体创建、更新、读取和删除)以及按上传者或受欢迎程度列出媒体的选项。 - -# 总结 - -在本章中,我们通过扩展 MERN 框架应用并利用 MongoDB GridFS 开发了一个媒体流应用。 - -除了为媒体添加基本的添加、更新、删除和列表功能外,我们还研究了基于 MERN 的应用如何允许用户上传视频文件,将这些文件作为块存储到 MongoDB GridFS 中,并根据需要将视频部分或全部流回到观看者。我们还介绍了带默认浏览器控件的`ReactPlayer`的基本用法,以流式传输视频文件。 - -在下一章中,我们将看到如何使用我们自己的控件和功能自定义`ReactPlayer`,以便用户有更多选项,例如播放列表中的下一个视频。此外,我们将讨论如何通过使用媒体视图的数据实现服务器端渲染来改进媒体细节的 SEO。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/09.md b/docs/full-stk-react-proj/09.md deleted file mode 100644 index 3f61b4e..0000000 --- a/docs/full-stk-react-proj/09.md +++ /dev/null @@ -1,998 +0,0 @@ -# 九、定制媒体播放器并改进 SEO - -用户访问媒体流应用主要是为了播放媒体和探索其他相关媒体。这使得媒体播放器和呈现相关媒体细节的视图对于流式应用至关重要。 - -在本章中,我们将重点为在上一章中开始构建的 MERN Mediastream 应用开发播放媒体页面。我们将讨论以下主题,以增强媒体播放功能,并帮助提升媒体内容在网络上的表现力,从而接触到更多用户: - -* 自定义`ReactPlayer`上的控件 -* 播放相关视频列表中的下一个 -* 自动播放相关媒体的列表 -* 服务器端使用数据呈现媒体视图以改进 SEO - -# 带有自定义媒体播放器的 MERN Mediastream - -上一章中开发的 MERN Mediastream 应用实现了一个简单的媒体播放器,带有默认的浏览器控件,一次播放一个视频。在本章中,我们将使用定制的`ReactPlayer`和相关媒体列表更新播放媒体的视图,该列表可设置为在当前视频结束时自动播放。使用定制播放器和相关播放列表更新的视图将如此屏幕截图所示: - -![](img/2f2b187f-d816-4640-a728-d3627fbf0a39.png) - -The code for the complete MERN Mediastream application is available on GitHub at [github.com/shamahoque/mern-mediastream](https://github.com/shamahoque/mern-mediastream). You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -以下组件树图显示了构成 MERN Mediastream 前端的所有自定义组件,突出显示了本章将改进或添加的组件: - -![](img/48f71397-98ac-4e74-92de-dfb7d6758f4c.jpg) - -本章中添加的新组件包括`MediaPlayer`组件和`RelatedMedia`组件,前者添加了带有自定义控件的`ReactPlayer`,后者包含相关视频列表。 - -# 播放媒体页面 - -当访问者想要在 MERN Mediastream 上查看特定媒体时,他们将被带到“播放媒体”页面,该页面将包含媒体详细信息、用于播放视频的媒体播放器以及下一步可以播放的相关媒体列表。 - -# 组件结构 - -我们将以一种允许媒体数据从父组件向下流到内部组件的方式在播放媒体页面中组成组件结构。在这种情况下,`PlayMedia`组件将是父组件,包含`RelatedMedia`组件,以及包含嵌套`MediaPlayer`组件的`Media`组件: - -![](img/c3e134b6-0f5f-4e9b-93cb-cce0c5a316b5.png) - -当访问单个媒体链接时,`PlayMedia`组件将从服务器装载并检索媒体数据和相关媒体列表。然后,相关数据将作为道具传递给`Media`和`RelatedMedia`子组件。 - -`RelatedMedia`组件将链接到其他相关媒体的列表,单击每个将使用新数据重新呈现`PlayMedia`组件和内部组件。 - -我们将更新[第 8 章](08.html)*构建流媒体应用*中开发的`Media`组件,添加定制的媒体播放器作为子组件。这个定制的`MediaPlayer`组件还将利用`PlayMedia`传递的数据流传输当前视频,并链接到相关媒体列表中的下一个视频。 - -在`PlayMedia`组件中,我们将添加一个自动播放切换,允许用户选择一个接一个地自动播放相关媒体列表中的视频。自动播放状态将通过`PlayMedia`组件进行管理,但此功能将要求处于状态的数据在视频结束于`MediaPlayer`时重新呈现,而`MediaPlayer`是嵌套的子组件,因此下一个视频可以在跟踪相关列表的同时自动开始播放。 - -为了实现这一点,`PlayMedia`组件需要提供一个状态更新方法作为道具,该方法将在`MediaPlayer`组件中用于更新这些组件之间共享和相互依赖的状态值。 - -考虑到这个组件结构,我们将扩展和更新 MERN Mediastream 应用,以实现功能性的播放媒体页面。 - -# 相关媒体列表 - -相关媒体列表将包括与给定视频属于同一类型的其他媒体记录,并按最高浏览次数排序。 - -# 相关列表 API - -为了从数据库中检索相关媒体列表,我们将在服务器上设置一个 API,该 API 将在`'/api/media/related/:mediaId'`接收 GET 请求。 - -`mern-mediastream/server/routes/media.routes.js`: - -```jsx -router.route('/api/media/related/:mediaId') - .get(mediaCtrl.listRelated) -``` - -`listRelated`控制器方法将查询媒体集合以查找与所提供媒体具有相同类型的记录,并将此媒体记录从返回的结果中排除。返回的结果将按最大视图数排序,并限制为前四个媒体记录。返回结果中的每个`media`对象还将包含发布媒体的用户的姓名和 ID。 - -`mern-mediastream/server/controllers/media.controller.js`: - -```jsx -const listRelated = (req, res) => { - Media.find({ "_id": { "$ne": req.media }, - "genre": req.media.genre}).limit(4) - .sort('-views') - .populate('postedBy', '_id name') - .exec((err, posts) => { - if (err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(posts) - }) -} -``` - -在客户端,我们将设置相应的`fetch`方法,该方法将在`PlayMedia`组件中使用,以检索使用此 API 的相关媒体列表。 - -`mern-mediastream/client/media/api-media.js`: - -```jsx -const listRelated = (params) => { - return fetch('/api/media/related/'+ params.mediaId, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 相关媒体组件 - -`RelatedMedia`组件将相关媒体列表作为`PlayMedia`组件的道具,并呈现列表中每个视频的详细信息以及视频快照。 - -我们使用`map`函数遍历媒体列表以呈现每个媒体项。 - -`mern-mediastream/client/media/RelatedMedia.js`: - -```jsx -{this.props.media.map((item, i) => { - return - ... video snapshot ... | ... media details ... - }) -} -``` - -为了显示视频快照,我们将使用一个不带控件的基本`ReactPlayer`。 - -`mern-mediastream/client/media/RelatedMedia.js`: - -```jsx - - - - -``` - -单击快照将重新呈现 PlayMedia 视图,以加载链接的媒体详细信息: - -![](img/087a88c8-0ee4-46c9-a3c7-0e0942fe6fb7.png) - -在快照旁边,我们将显示每个视频的详细信息,包括标题、流派、创建日期和视图数。 - -`mern-mediastream/client/media/RelatedMedia.js`: - -```jsx -{item.title} - {item.genre} - - {(new Date(item.created)).toDateString()} - -{item.views} views -``` - -要在视图中使用这个`RelatedMedia`组件,我们将把它添加到`PlayMedia`组件中。 - -# PlayMedia 组件 - -`PlayMedia`组件由`Media`和`RelatedMedia`子组件以及自动播放切换组成,在视图中加载时向这些组件提供数据。为了在用户访问单个媒体链接时呈现`PlayMedia`组件,我们将在`MainRouter`中添加一个`Route`以在`'/media/:mediaId'`处挂载`PlayMedia`。 - -`mern-mediastream/client/MainRouter.js`: - -```jsx - -``` - -当`PlayMedia`组件挂载时,它将根据路由链路中的`media ID`参数,使用`loadMedia`功能从服务器获取媒体数据和相关媒体列表。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -loadMedia = (mediaId) => { - read({mediaId: mediaId}).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({media: data}) - listRelated({ - mediaId: data._id}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({relatedMedia: data}) - } - }) - } - }) - } -``` - -`loadMedia`函数使用媒体 ID 和`read`API`fetch`方法从服务器检索媒体详细信息。然后,它使用`listRelated`API fetch 方法从服务器检索相关媒体列表,并将值设置为 state。 - -当组件安装和接收道具时,使用`mediaId`值调用`loadMedia`函数。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -componentDidMount = () => { - this.loadMedia(this.match.params.mediaId) -} -componentWillReceiveProps = (props) => { - this.loadMedia(props.match.params.mediaId) -} -``` - -要在组件挂载时访问路由 URL 中的`mediaId`参数,我们需要访问组件构造函数中的 react router`match`对象。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -constructor({match}) { - super() - this.state = { - media: {postedBy: {}}, - relatedMedia: [], - autoPlay: false, - } - this.match = match -} -``` - -组件状态中存储的媒体和相关媒体列表值用于将相关道具传递给视图中添加的子组件。例如,`RelatedMedia`组件仅在相关媒体列表包含任何项目时才会呈现,并作为道具传递给列表。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -{this.state.relatedMedia.length > 0 && - ()} -``` - -在本章后面的*自动播放相关媒体*部分中,只有当相关媒体列表的长度大于零时,我们才会在`RelatedMedia`组件上方添加自动播放切换组件。我们还将讨论将作为道具传递给`Media`组件的`handleAutoPlay`方法的实现,以及媒体详细信息对象,以及相关媒体列表中第一个媒体的视频 URL 作为下一个要播放的 URL。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -const nextUrl = this.state.relatedMedia.length > 0 - ? `/media/${this.state.relatedMedia[0]._id}` : '' - -``` - -`Media`组件呈现媒体细节,同时也是一个媒体播放器,允许观众控制视频流。 - -# 媒体播放器 - -我们将自定义`ReactPlayer`上的播放器控件,以自定义外观和功能替换默认浏览器控件,如此屏幕截图所示: - -![](img/2c97a393-aea9-4d2b-ad38-f61da596bf40.png) - -这些控件将添加到视频下方,包括进度搜索栏、播放、暂停、下一步、音量、循环和全屏选项,以及显示播放的持续时间。 - -# 更新媒体组件 - -我们将创建一个新的`MediaPlayer`组件,该组件将包含定制的`ReactPlayer`。在`Media`组件中,我们将用新的`MediaPlayer`组件替换之前使用的`ReactPlayer`,并传递视频源 URL、下一个视频的 URL 和`handleAutoPlay`方法,它们作为`props`从`PlayMedia`组件接收。 - -`mern-mediastream/client/media/Media.js`: - -```jsx -const mediaUrl = this.props.media._id - ? `/api/media/video/${this.props.media._id}` - : null -... - -``` - -# 正在初始化媒体播放器 - -`MediaPlayer`组件将包含`ReactPlayer`组件,在添加自定义控件和处理代码之前,从初始控件值开始。 - -首先,我们将初始控制值设置为`state`。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -state = { - playing: true, - volume: 0.8, - muted: false, - played: 0, - loaded: 0, - duration: 0, - ended:false, - playbackRate: 1.0, - loop: false, - fullscreen: false, - videoError: false -} -``` - -在视图中,我们将使用从`Media`组件发送的道具,使用控制值和源 URL 添加`ReactPlayer`。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -const { playing, ended, volume, muted, loop, played, loaded, duration, playbackRate, fullscreen, videoError } = this.state -... - -``` - -我们将获得该播放器的一个引用,因此可以在自定义控件的更改处理代码中使用它。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -ref = player => { - this.player = player -} -``` - -如果无法加载源视频,我们将捕获错误。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -videoError = e => { - this.setState({videoError: true}) -} -``` - -然后,我们将有条件地在视图中显示一条错误消息。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -{videoError &&

Video Error. Try again later.

} -``` - -# 自定义媒体控件 - -我们将在视频下方添加自定义播放器控制元素,并使用`ReactPlayer`API 提供的选项和事件操作它们的功能 - -# 播放、暂停和重播 - -用户将能够播放、暂停和重放当前视频,我们将使用绑定到`ReactPlayer`属性和事件的`Material-UI`组件实现这三个选项: - -![](img/52a12bc4-9e39-4efc-bfba-c5d77ff41219.png) - -为了实现播放、暂停和重播功能,我们将根据视频是正在播放、暂停还是已结束,有条件地添加播放、暂停或重播图标按钮。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - - {playing ? 'pause': (ended ? 'replay' : 'play_arrow')} - -``` - -当用户点击按钮时,我们将更新状态中的播放值,从而更新`ReactPlayer`。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -playPause = () => { - this.setState({ playing: !this.state.playing }) -} -``` - -# 下一场比赛 - -用户可以使用“下一步”按钮播放相关媒体列表中的下一个视频: - -![](img/26dd9561-6f17-4f76-b118-a5755e88fef7.png) - -如果相关列表不包含任何介质,则“下一步”按钮将被禁用。“播放下一个”图标基本上会链接到作为道具从`PlayMedia`传入的下一个 URL 值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - - - skip_next - - -``` - -点击此`next`按钮将重新加载包含新媒体详细信息的`PlayMedia`组件,并开始播放视频。 - -# 循环结束 - -用户还可以使用“循环”按钮将当前视频设置为在循环中继续播放: - -![](img/bd40c922-9269-4e12-abb0-13d1cc516a7c.png) - -我们将设置一个循环图标按钮,该按钮将以不同的颜色呈现,以指示它是已设置还是未设置。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - - loop - -``` - -点击循环图标按钮时,更新状态中的`loop`值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onLoop = () => { - this.setState({ loop: !this.state.loop }) -} -``` - -我们需要捕获`onEnded`事件,检查`loop`是否已设置为 true,以便`playing`值可以相应地更新。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onEnded = () => { - if(this.state.loop){ - this.setState({ playing: true}) - }else{ - this.setState({ ended: true, playing: false }) - } -} -``` - -因此,如果`loop`设置为 true,当视频结束时,它将再次开始播放,否则它将停止播放并呈现 replay 按钮。 - -# 音量控制 - -为了控制正在播放的视频的音量,用户可以选择增大或减小音量,以及静音或取消静音。渲染的卷控件将根据用户操作和卷的当前值进行更新: - -* 如果音量增大,则会呈现音量增大图标: - -![](img/74ba3641-fea7-48d8-8b15-8a36ad7f0df4.png) - -* 如果用户将音量减小到零,将呈现音量关闭图标: - -![](img/cef09c93-73fe-48bc-a36b-d97965b28e14.png) - -* 如果用户单击图标使音量静音,将显示音量静音图标按钮: - -![](img/b34a64a7-d26b-487d-9783-48b66f54b040.png) - -为了实现这一点,我们将根据`volume`、`muted`、`volume_up`和`volume_off`值有条件地在`IconButton`中呈现不同的图标: - -```jsx - - {volume > 0 && !muted && 'volume_up' || - muted && 'volume_off' || - volume==0 && 'volume_mute'} - -``` - -单击此音量按钮时,它将使音量静音或取消静音。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -toggleMuted = () => { - this.setState({ muted: !this.state.muted }) -} -``` - -为了允许用户增加或减少音量,我们将添加一个`input range`,允许用户在`0`和`1`之间设置音量值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - -``` - -更改输入范围上的`value`将相应地设置`volume`值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - setVolume = e => { - this.setState({ volume: parseFloat(e.target.value) }) - } -``` - -# 进度控制 - -我们将使用 Material UI`LinearProgress`组件来指示缓冲了多少视频以及播放了多少视频。然后,我们将此组件与`range input`相结合,使用户能够将时间滑块移动到视频的不同部分并从那里播放: - -![](img/3406e0dd-b022-472d-aec1-16d1f4697a25.png) - -`LinearProgress`组件将采用`played`和`loaded`值以不同的颜色显示: - -```jsx - -``` - -要在视频播放或加载时更新`LinearProgress`组件,我们将使用`onProgress`事件侦听器设置`played`和`loaded`的当前值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onProgress = progress => { - if (!this.state.seeking) { - this.setState({played: progress.played, loaded: progress.loaded}) - } -} -``` - -对于时间滑动控制,我们将添加`range input`元素,并使用 CSS 样式将其放置在`LinearProgress`组件上。范围的当前值将随着`played`值的变化而更新,因此范围值似乎随着视频的进展而移动。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - -``` - -如果用户自己拖动并设置范围选择器,我们将添加代码来处理`onMouseDown`、`onMouseUp`和`onChange`事件,以便从所需位置启动视频。 - -当用户按住鼠标开始拖动时,我们会将 seeking 设置为 true,这样进度值就不会设置为`played`和`loaded`。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onSeekMouseDown = e => { - this.setState({ seeking: true }) -} -``` - -当范围值发生变化时,在检查用户是否将时间滑块拖到视频末尾后,我们将设置`played`值和`ended`值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onSeekChange = e => { - this.setState({ played: parseFloat(e.target.value), - ended: parseFloat(e.target.value) >= 1 }) -} -``` - -当用户完成拖动并抬起鼠标时,我们将`seeking`设置为`false`,并将播放器的`seekTo`值设置为`range input`中的当前值。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onSeekMouseUp = e => { - this.setState({ seeking: false }) - this.player.seekTo(parseFloat(e.target.value)) -} -``` - -这样,用户将能够选择视频的任何部分,还可以获得视频流传输时间进度的视觉信息。 - -# 全屏 - -用户可以通过单击控件中的全屏按钮全屏查看视频: - -![](img/94d9b7b1-191f-48ae-87bf-ad155addb093.png) - -为了实现视频的全屏选项,我们将使用`screenfull`npm 模块跟踪视图何时处于全屏状态,并使用`react-dom`中的`findDOMNode`指定哪个 DOM 元素将与`screenfull`一起全屏显示。 - -要设置`fullscreen`代码,我们首先安装`screenfull`: - -```jsx -npm install screenfull --save -``` - -然后将`screenfull`和`findDOMNode`导入`MediaPlayer`组件。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -import screenfull from 'screenfull' -import { findDOMNode } from 'react-dom' -``` - -当`MediaPlayer`组件挂载时,我们将添加一个`screenfull`更改事件监听器,该监听器将更新状态中的`fullscreen`值,以指示屏幕是否处于全屏状态。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -componentDidMount = () => { - if (screenfull.enabled) { - screenfull.on('change', () => { - let fullscreen = screenfull.isFullscreen ? true : false - this.setState({fullscreen: fullscreen}) - }) - } -} -``` - -在视图中,我们将为`fullscreen`添加一个`icon`按钮和其他控制按钮。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - - fullscreen - -``` - -当用户点击此按钮时,我们将使用`screenfull`和`findDOMNode`使视频播放器全屏显示。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onClickFullscreen = () => { - screenfull.request(findDOMNode(this.player)) -} -``` - -然后,用户可以全屏观看视频,随时按*Esc*退出全屏并返回 PlayMedia 视图。 - -# 播放时长 - -在媒体播放器的“自定义媒体控制”部分中,我们希望以可读的时间格式显示已经过去的时间以及视频的总持续时间: - -![](img/bc1f8bcf-a2d9-4303-9243-5ec1f87e922d.png) - -为了显示时间,我们可以使用 HTML`time`元素。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx - / - -``` - -我们将使用`onDuration`事件获取视频的`duration`值,然后将其设置为状态,这样就可以在时间元素中渲染视频。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onDuration = (duration) => { - this.setState({ duration }) -} -``` - -为了使持续时间值可读,我们将使用以下`format`函数。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -format = (seconds) => { - const date = new Date(seconds * 1000) - const hh = date.getUTCHours() - let mm = date.getUTCMinutes() - const ss = ('0' + date.getUTCSeconds()).slice(-2) - if (hh) { - mm = ('0' + date.getUTCMinutes()).slice(-2) - return `${hh}:${mm}:${ss}` - } - return `${mm}:${ss}` -} -``` - -`format`函数以秒为单位获取持续时间值,并将其转换为`hh/mm/ss`格式。 - -添加到自定义媒体播放器中的控件主要基于`ReactPlayer`模块中的一些可用功能,以及作为文档提供的示例。有更多选项可用于进一步的定制和扩展,这可能会根据特定的功能需求进行更多的探索。 - -# 自动播放相关媒体 - -我们将通过在`PlayMedia`中添加切换来完成前面讨论的自动播放功能,并在`MediaPlayer`组件中实现`handleAutoplay`方法,视频结束时需要调用该方法。 - -# 切换自动播放 - -除了允许用户设置自动播放外,切换还将指示当前是否设置了自动播放: - -![](img/cc0fa409-f523-44c3-9234-d5ebe695c302.png) - -对于自动播放切换,我们将使用`Material-UI``Switch`组件和`FormControlLabel`组件,并将其添加到`RelatedMedia`组件上方的`PlayMedia`组件中,仅当相关媒体列表中有媒体时,才会进行渲染。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx - - } - label={this.state.autoPlay? 'Autoplay ON':'Autoplay OFF'} -/> -``` - -为了处理对切换的更改并将其反映在状态的`autoplay`值中,我们将使用以下`onChange`处理函数。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -handleChange = (event) => { - this.setState({ autoPlay: event.target.checked }) -} -``` - -# 处理跨组件的自动播放 - -`PlayMedia`将`handleAutoPlay`方法传递给`Media`组件,作为视频结束时`MediaPlayer`组件使用的道具。 - -此处所需的功能是,当视频结束时,如果 autoplay 设置为 true 且当前相关媒体列表不为空,`PlayMedia`应加载相关列表中第一个视频的媒体详细信息。反过来,`Media`和`MediaPlayer`组件应使用新媒体详细信息进行更新,开始播放新视频,并适当渲染播放器上的控件。`RelatedMedia`组件中的列表也应该随着从列表中删除的当前媒体而更新,因此只有剩余的播放列表项可见。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -handleAutoplay = (updateMediaControls) => { - let playList = this.state.relatedMedia - let playMedia = playList[0] - - if(!this.state.autoPlay || playList.length == 0 ) - return updateMediaControls() - - if(playList.length > 1){ - playList.shift() - this.setState({media: playMedia, relatedMedia:playList}) - }else{ - listRelated({ - mediaId: playMedia._id}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({media: playMedia, relatedMedia: data}) - } - }) - } - } -``` - -当视频在`MediaPlayer`组件中结束时,`handleAutoplay`方法处理以下事项: - -* 它从`MediaPlayer`组件中的`onEnded`事件侦听器获取回调函数。如果未设置自动播放或相关媒体列表为空,则将执行此回调,以便呈现`MediaPlayer`上的控件以显示视频已结束。 -* 如果设置了自动播放,并且列表中有多个相关媒体,则: - * 相关媒体列表中的第一项被设置为处于状态的当前媒体对象,以便可以对其进行渲染 - * 删除第一个现在将开始在视图中播放的项目,即可更新相关媒体列表 - -* 如果设置了自动播放且相关媒体列表中只有一个项目,则最后一个项目将设置为“媒体”,以便开始播放,并调用`listRelated`fetch 方法以使用最后一个项目的相关媒体重新填充 RelatedMedia 视图。 - -# 在 MediaPlayer 中视频结束时更新状态 - -`MediaPlayer`从`PlayMedia`接收`handleAutoplay`方法作为道具。只有当当前视频的`loop`设置为`false`时,我们才会更新`onEnded`事件的侦听器代码以执行此方法。 - -`mern-mediastream/client/media/MediaPlayer.js`: - -```jsx -onEnded = () => { - if(this.state.loop){ - this.setState({ playing: true}) - }else{ - this.props.handleAutoplay(() => { - this.setState({ ended: true, - playing: false }) - }) - } -} -``` - -在`PlayMedia`中确定未设置自动播放或相关媒体列表为空后,向`handleAutoplay`方法传递回调函数,以将播放设置为 false,并呈现回放图标按钮,而不是播放或暂停图标按钮。 - -自动播放功能将通过此实现一个接一个地继续播放相关视频。此实现演示了当值相互依赖时跨组件更新状态的另一种方法 - -# 使用数据进行服务器端渲染 - -搜索引擎优化对于任何向用户交付内容并希望使内容易于查找的 web 应用都很重要。一般来说,任何网页上的内容都有更好的机会获得更多的观众,如果这些内容很容易被搜索引擎阅读的话。当搜索引擎机器人访问 web URL 时,它将获得服务器端呈现的输出。因此,要使内容可发现,内容应该是服务器端呈现输出的一部分。 - -在 MERN Mediastream 中,我们将使用使媒体详细信息在搜索引擎结果中流行的案例来演示如何将数据注入 MERN 应用中的服务器端渲染视图。我们将着重于通过为返回到`'/media/:mediaId'`路径的`PlayMedia`组件注入数据来实现服务器端渲染。这里概述的一般步骤可用于使用其他视图的数据实现 SSR。 - -# 路由配置 - -为了在服务器上呈现 React 视图时加载这些视图的数据,我们将使用 React 路由配置 npm 模块,该模块为 React 路由提供静态路由配置帮助: - -```jsx -npm install react-router-config --save -``` - -我们将创建一个路由配置文件,用于将路由与服务器上的传入请求 URL 相匹配,以检查在服务器返回呈现的标记之前是否必须注入数据。 - -对于 MERN Mediastream 中的路由配置,我们只列出呈现`PlayMedia`组件的路由。 - -`mern-mediastream/client/routeConfig.js`: - -```jsx -import PlayMedia from './media/PlayMedia' -import { read } from './media/api-media.js' -const routes = [ - { - path: '/media/:mediaId', - component: PlayMedia, - loadData: (params) => read(params) - } -] -export default routes -``` - -对于该路由和组件,我们将从`api-media.js`指定`read`获取方法作为负载数据方法。然后,当服务器生成标记时,它将用于检索数据并将数据注入 PlayMedia 视图。 - -# 正在更新 Express 服务器的 SSR 代码 - -我们将更新`server/express.js`中现有的基本服务器端渲染代码,为将渲染服务器端的 React 视图添加数据加载功能。 - -# 使用路由配置加载数据 - -我们将定义`loadBranchData`来使用`react-router-config`中的`matchRoutes`,以及路由配置文件中定义的路由来查找与传入请求 URL 匹配的路由。 - -`mern-mediastream/server/express.js`: - -```jsx -import { matchRoutes } from 'react-router-config' -import routes from './../client/routeConfig' -const loadBranchData = (location) => { - const branch = matchRoutes(routes, location) - const promises = branch.map(({ route, match }) => { - return route.loadData - ? route.loadData(branch[0].match.params) - : Promise.resolve(null) - }) - return Promise.all(promises) -} -``` - -如果找到匹配的路由,则将执行任何相关的`loadData`方法,以返回包含获取数据的`Promise`,如果没有`loadData`方法,则返回`null`。 - -每当服务器收到请求时,这里定义的`loadBranchData`都需要调用,因此如果找到匹配的路由,我们可以在呈现服务器端时获取相关数据并将其注入 React 组件。 - -# 同构提取 - -我们还将在`express.js`中导入同构 fetch,以便`read`fetch 方法或我们为客户机定义的任何其他 fetch 现在可以在服务器上使用。 - -`mern-mediastream/server/express.js`: - -```jsx -import 'isomorphic-fetch' -``` - -# 绝对网址 - -使用`isomorphic-fetch`的一个问题是,它当前要求获取 URL 是绝对的。因此,我们需要将`api-media.js`中定义的`read`获取方法中使用的 URL 更新为绝对 URL。 - -我们将在`config.js`中设置一个`config`变量,而不是在代码中硬编码服务器地址。 - -`mern-mediastream/config/config.js`: - -```jsx -serverUrl: process.env.serverUrl || 'http://localhost:3000' -``` - -然后我们将更新`api-media.js`中的`read`方法,使其使用绝对 URL 调用服务器上的读取 API。 - -`mern-mediastream/client/media/api-media.js:` - -```jsx -import config from '../../config/config' -const read = (params) => { - return fetch(config.serverUrl +'/api/media/' + params.mediaId, { - method: 'GET' - }).then((response) => { ... }) -``` - -这将使`read`fetch 调用与`isomorphic-fetch`兼容,因此它可以在服务器上正常使用。 - -# 将数据注入 React 应用 - -在后端现有的服务器端渲染代码中,我们使用`ReactDOMServer`将 React 应用转换为标记。我们将在`express.js`中更新此代码,在使用`loadBranchData`方法获取`MainRouter`后,将数据作为道具注入`MainRouter`。 - -`mern-mediastream/server/express.js`: - -```jsx -... -loadBranchData(req.url).then(data => { - const markup = ReactDOMServer.renderToString( - - - - < MainRouter data={data}/> - - - - ) -... -}).catch(err => { - res.status(500).send("Data could not load") - }) -... - -``` - -当服务器生成标记时,要将损坏的数据添加到渲染的 To.T0:Up 组件中,我们需要更新客户端代码来考虑服务器注入的数据。 - -# 在客户端代码中应用服务器注入的数据 - -在客户端,我们将访问从服务器传递的数据,并将其添加到 PlayMedia 视图中。 - -# 将数据道具从主路由传递到 PlayMedia - -在使用`ReactDOMServer.renderToString`生成标记时,我们将预加载的数据作为道具传递给`MainRouter`。我们可以在`MainRouter`的构造函数中访问该数据属性。 - -`mern-mediastream/client/MainRouter.js`: - -```jsx - constructor({data}) { - super() - this.data = data - } -``` - -为了允许`PlayMedia`访问此数据,我们将更改`PlayMedia`的`Route`组件,以将此数据作为道具传递。 - -`mern-mediastream/client/MainRouter.js`: - -```jsx - ( - - )} /> -``` - -# 在 PlayMedia 中呈现接收到的数据 - -在`PlayMedia`组件中,我们将检查从服务器传递的数据,并将值设置为 state,以便在视图中呈现媒体详细信息。 - -`mern-mediastream/client/media/PlayMedia.js`: - -```jsx -... -render() { - if (this.props.data && this.props.data[0] != null) { - this.state.media = this.props.data[0] - this.state.relatedMedia = [] - } -... -} -``` - -这将使用 PlayMedia 视图中注入的媒体数据生成服务器生成的标记。 - -# 用数据检查 SSR 的实施 - -对于 MERN Mediastream,呈现 PlayMedia 的任何链接现在都应该在服务器端生成标记,并预加载媒体详细信息。我们可以通过在关闭 JavaScript 的浏览器中打开应用 URL 来验证服务器端呈现数据的实现是否正常工作。我们将研究如何在 Chrome 浏览器中实现这一点,以及生成的视图应该向用户和搜索引擎显示什么。 - -# 铬试验 - -在 Chrome 中测试这个实现只需要更新 Chrome 设置,并在 JS 被阻止的选项卡中加载应用。 - -# 加载启用 JS 的页面 - -首先,在 Chrome 中打开应用,然后浏览到任何媒体链接,让它在启用 JavaScript 的情况下正常呈现。这将显示已实现的 PlayMedia 视图,其中包含功能正常的媒体播放器和相关媒体列表。 - -# 从设置中禁用 JS - -接下来,在 Chrome 上禁用 JavaScript。为此,您可以转到`chrome://settings/content/javascript`的高级设置,并使用切换来阻止 JavaScript: - -![](img/7b2d5eb9-1018-48e8-9041-05b34aa960dc.png) - -现在,刷新 MERN Mediastream 选项卡中的媒体链接,地址 URL 旁边会有一个图标,显示 JavaScript 确实被禁用: - -![](img/5d31d57c-a0f6-42de-b110-195cba56852b.png) - -# 阻止 JS 的播放媒体视图 - -PlayMedia 视图的渲染应与下图类似,仅填充媒体详细信息。但用户界面不再是交互式的,因为 JavaScript 被阻止,只有默认的浏览器控件可操作: - -![](img/f4559f8f-6d44-4ccd-a87a-6ab9e218e3b1.png) - -这是搜索引擎机器人将读取的媒体内容,以及当浏览器上没有加载 JavaScript 时用户将看到的内容。 - -MERN Mediastream 现在拥有完全可操作的媒体播放工具,允许用户轻松浏览和播放视频。此外,由于使用预加载数据进行服务器端渲染,显示单个媒体内容的媒体视图现在已通过搜索引擎优化。 - -# 总结 - -在本章中,我们通过使用`ReactPlayer`提供的选项添加自定义媒体播放器控件,完全升级了 MERN Mediastream 上的播放媒体页面从数据库检索相关媒体后,启用相关媒体播放列表的自动播放功能,并在服务器上呈现视图时,通过从服务器注入数据使媒体详细信息搜索引擎可读。 - -现在,我们已经利用 MERN stack 技术探索了流媒体和 SEO 等高级功能,在接下来的章节中,我们将通过将虚拟现实元素合并到 web 应用中,进一步测试该堆栈的潜力。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/10.md b/docs/full-stk-react-proj/10.md deleted file mode 100644 index d8f744e..0000000 --- a/docs/full-stk-react-proj/10.md +++ /dev/null @@ -1,1050 +0,0 @@ -# 十、基于 Web 的虚拟现实游戏开发 - -**虚拟现实**(**VR**)和**增强现实**(**AR**技术的出现正在改变用户与软件交互的方式,进而改变他们周围的世界。VR 和 AR 的可能应用是数不胜数的,尽管游戏行业是早期采用者,但这些快速发展的技术有可能改变多学科和多行业的模式。 - -为了演示 MERN stack 与 React 360 如何轻松地将虚拟现实功能添加到任何 web 应用中,我们将在本章和下一章中讨论并开发一款基于 web 的动态虚拟现实游戏。 - -通过涵盖以下主题,本章将重点介绍定义 VR 游戏的功能和使用 React 360 开发游戏视图: - -* 虚拟现实游戏规范 -* 开发 3D VR 应用的关键概念 -* 开始使用 React 360 -* 定义游戏数据 -* 实现游戏视图 -* 捆绑 React 360 代码以与 MERN 骨架集成 - -# 梅恩虚拟现实游戏 - -MERN VR 游戏 web 应用将通过扩展 MERN 框架并使用 React 360 集成 VR 功能来开发。这将是一个动态的、基于网络的虚拟现实游戏应用,注册用户可以在其中制作自己的游戏,任何访问该应用的人都可以玩这些游戏: - -![](img/7e939bf7-7e64-4628-851e-1699529a9d3e.png) - -游戏本身的功能将非常简单,可以将虚拟现实引入基于 MERN 的应用,而不必深入研究 React 360 的高级概念,这些概念可用于实现更复杂的虚拟现实功能。 - -The code to implement features of the VR game using React 360 is available on GitHub at [github.com/shamahoque/MERNVR](https://github.com/shamahoque/MERNVR). You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -# 游戏特色 - -MERN VR 游戏中的每个游戏基本上都是一个不同的 VR 世界,用户可以与 360 度全景世界中不同位置的 3D 对象进行交互。 - -游戏玩法类似于寻宝游戏,为了完成每个游戏,用户必须找到并收集与每个游戏的线索或描述相关的 3D 对象。这意味着游戏世界将包含一些玩家可以收集的虚拟现实对象,以及一些无法收集的虚拟现实对象,但游戏制作者可以将其作为道具或提示放置。 - -# 本章的重点 - -在本章中,我们将使用 React 360 构建游戏功能,主要关注与实现前面定义的功能相关的概念。一旦游戏功能准备就绪,我们将讨论如何捆绑 React 360 代码,并准备与[第 11 章](11.html)*中开发的 MERN 应用代码集成,*使用 MERN*使 VR 游戏动态化。* - -# 反应 360 度 - -React 360 可以使用 React 中相同的声明式和基于组件的方法构建虚拟现实体验。React 360 的底层技术利用 Three.js JavaScript 3D 引擎在任何兼容的 web 浏览器中使用 WebGL 呈现 3D 图形,还提供使用 web VR API 访问 VR 耳机的功能。 - -尽管 React 360 构建在 React 之上,并且应用在浏览器中运行,但 React 360 与 React Native 有很多共同之处,因此使 React 360 应用跨平台运行。这也意味着来自 React Native 的概念也适用于 React 360。涵盖所有 React 360 概念超出了本书的范围,因此我们将重点关注构建游戏并将其与 MERN stack web 应用集成所需的概念。 - -# 开始使用 React 360 - -React 360 提供开发人员工具,可以轻松开始开发新的 React 360 项目。React 360 文档中详细介绍了开始的步骤,因此我们将仅总结这些步骤,并指出与开发游戏相关的文件。 - -由于我们已经为 MERN 应用安装了节点,我们可以从安装 React 360 CLI 工具开始: - -```jsx -npm install -g react-360-cli -``` - -使用此 CLI 工具创建新的应用并安装所需的依赖项: - -```jsx -react-360 init MERNVR -``` - -这将在当前目录中名为`MERNVR`的文件夹中添加应用以及所有必要的文件。最后,我们可以从命令行进入此文件夹,并运行应用: - -```jsx -npm start -``` - -`start`命令将初始化本地开发服务器,默认 React 360 应用可在`http://localhost:8081/index.html`浏览器中查看。 - -为了更新 starter 应用并实现我们的游戏功能,我们将主要修改`index.js`文件中的代码,并对`MERNVR`项目文件夹中的`client.js`文件进行一些小的更新。 - -`index.js`中 starter 应用的默认代码应如下所示,它表示欢迎您在浏览器的 360 世界中反应 360 文本: - -```jsx -import React from 'react' -import { AppRegistry, StyleSheet, Text, View } from 'react-360' - -export default class MERNVR extends React.Component { - render() { - return ( - - - - Welcome to React 360 - - - - ) - } -} - -const styles = StyleSheet.create({ - panel: { - // Fill the entire surface - width: 1000, - height: 600, - backgroundColor: 'rgba(255, 255, 255, 0.4)', - justifyContent: 'center', - alignItems: 'center', - }, - greetingBox: { - padding: 20, - backgroundColor: '#000000', - borderColor: '#639dda', - borderWidth: 2, - }, - greeting: { - fontSize: 30, - } -}) - -AppRegistry.registerComponent('MERNVR', () => MERNVR) -``` - -此`index.js`文件包含应用的内容和主代码。`client.js`中的代码包含将浏览器连接到`index.js`中 React 应用的样板文件。starter 项目文件夹中的默认`client.js`如下所示: - -```jsx -import {ReactInstance} from 'react-360-web' - -function init(bundle, parent, options = {}) { - const r360 = new ReactInstance(bundle, parent, { - // Add custom options here - fullScreen: true, - ...options, - }) - - // Render your app content to the default cylinder surface - r360.renderToSurface( - r360.createRoot('MERNVR', { /* initial props */ }), - r360.getDefaultSurface() - ) - - // Load the initial environment - r360.compositor.setBackground(r360.getAssetURL('360_world.jpg')) -} - -window.React360 = {init} -``` - -此代码基本上执行`index.js`中定义的 React 代码,基本上创建 React 360 的新实例,并通过将其附加到 DOM 来加载 React 代码 - -设置了默认的 React 360 项目后,在修改代码以实现游戏之前,我们将首先了解与开发 3D VR 体验相关的一些关键概念,以及这些概念如何应用于 React 360。 - -# 开发虚拟现实游戏的关键概念 - -在为游戏创建虚拟现实内容和 360 度互动体验之前,首先了解虚拟世界的一些关键方面以及如何使用 React 360 组件来处理这些虚拟现实概念是很重要的。 - -# 等矩形全景图像 - -游戏的虚拟现实世界将由全景图像组成,全景图像作为背景图像添加到 React 360 环境中。 - -全景图像通常是 360 度图像或投影到完全包围观众的球体上的球形全景图像。360 度全景图像的常用格式是等矩形格式。React 360 度目前支持等矩形图像的单声道和立体声格式。 - -To learn more about the 360 image and video support in React 360, refer to the React 360 docs at [facebook.github.io/react-360/docs/setup.html](https://facebook.github.io/react-360/docs/setup.html). - -此处显示的图像是等矩形 360 度全景图像的示例。要在 MERN VR 游戏中设置游戏的世界背景,我们将使用以下图像: - -![](img/20b36d83-b289-48a3-9f24-a939524e8067.png) - -An equirectangular panoramic image consists of a single image with an aspect ratio of 2:1, where the width is twice the height. These images are created with a special 360 degree camera. An excellent source of equirectangular images is Flickr, you just need to search for the `equirectangular` tag. - -通过在 React 360 环境中使用等矩形图像设置背景场景来创建游戏世界,将使虚拟现实体验身临其境,并将用户转移到虚拟位置。为了增强这种体验并在这个虚拟现实世界中有效地添加 3D 对象,我们需要更多地了解与 3D 空间相关的布局和坐标系。 - -# 三维位置–坐标和变换 - -我们需要了解虚拟现实世界空间中的位置和方向,以便将 3D 对象放置在所需位置,并使虚拟现实体验更真实。 - -# 三维坐标系 - -对于三维空间中的贴图,React 360 使用类似于 OpenGL®三维坐标系的基于米的三维坐标系,允许单个组件相对于其父组件中的布局进行三维变换、移动或旋转。 - -The 3D coordinate system used in React 360 is a right-handed system. This means the positive x-axis is to the right, the positive y-axis is up, and the positive z-axis is backwards. This provides a better mapping with common coordinate systems of the world space in assets and 3D world modeling.  - -如果我们尝试可视化 3D 空间,用户将从下一幅图像中所示的**X-Y-Z**轴的中心开始。**Z**轴指向用户前方,用户向外看**-Z**轴方向。**Y**轴上下运行,而**X**轴从一侧到另一侧运行。 - -图像中的曲线箭头显示正旋转值的方向: - -![](img/df215bc6-9a8f-4dfa-8e14-65b6d6f120d0.png) - -# 使改变 - -在以下两幅图像中,通过更改正在渲染 3D 对象的 React 360`Entity`组件的样式属性中的`transform`属性,3D book 对象被放置在两个不同的位置和方向。此处的变换基于 React 的变换样式,React 360 扩展为全 3D,考虑到 X-Y-Z 轴: - -![](img/c060ac74-3a84-4efe-8860-83ea3c525d15.png) - -`transform`属性以键和值数组的形式添加到`style`属性中的组件中,格式如下: - -```jsx -style={{ ... - transform: [ - {TRANSFORM_COMMAND: TRANSFORM_VALUE}, - ... - ] -... }} -``` - -与我们游戏中要放置的 3D 对象相关的变换命令和值是`translate [x, y, z]`(以米为单位)、`rotate [x, y, z]`(以度为单位)和`scale`(以确定对象在所有轴上的大小)。我们还将使用矩阵命令,该命令接受一个值作为表示平移、旋转和缩放值的 16 个数字的数组。 - -To learn more about the React 360 3D coordinates and transforms, take a look at the React 360 docs at [facebook.github.io/react-360/docs/setup.html](https://facebook.github.io/react-360/docs/setup.html). - -# 反应 360 个组件 - -React 360 提供了一系列组件,可直接用于创建游戏的 VR 用户界面。接下来,我们将总结用于构建游戏功能的特定组件。 - -# 核心部件 - -React 360 的核心组件包括 React Native 的内置组件:`Text`和`View`。在游戏中,我们将使用这两个组件在游戏世界中添加内容。 - -# 看法 - -`View`组件是在 React Native 中构建用户界面的最基本组件,它直接映射到运行 React Native 的任何平台上的本机视图。在我们的情况下,它将出现在浏览器上的`
`: - -```jsx - - Hello - -``` - -`View`组件通常用作其他组件的容器,它可以嵌套在其他视图中,并且可以有零到多个任何类型的子组件。 - -我们将使用`View`组件来保存游戏世界视图,并将 3D 对象实体和文本添加到游戏中。 - -# 文本 - -`Text`组件是用于显示文本的 React 原生组件,我们将使用它在 3D 空间中渲染字符串,方法是将`Text`组件放置在`View`组件中: - -```jsx - - Welcome to the MERN VR Game - -``` - -# 3D VR 体验组件 - -React 360 提供了一套自己的组件来创建虚拟现实体验。具体来说,我们将使用`Entity`组件添加 3D 对象,并使用`VrButton`组件捕获用户的点击。 - -# 实体 - -为了向游戏世界添加 3D 对象,我们将使用`Entity`组件,它允许我们在 React 360 中渲染 3D 对象: - -```jsx - -``` - -包含特定 3D 对象信息的文件使用`source`属性添加到`Entity`组件中。“源”属性使用键值对对象将资源文件类型映射到它们的位置。React 360 支持波前 OBJ 文件格式,这是 3D 模型的常见表示形式。所以在 source 属性中,`Entity`组件支持以下键: - -* `obj`:OBJ 格式模型的位置 -* `mtl`:MTL 格式材料的位置(与 OBJ 配套) - -`obj`和`mtl`属性的值指向这些文件的位置,可以是静态字符串、`asset()`调用、`require()`语句或 URI 字符串。 - -OBJ (or .OBJ) is a geometry definition file format first developed by Wavefront Technologies. It is a simple data format that represents 3D geometry as a list of vertices and texture vertices. OBJ coordinates have no units, but OBJ files can contain scale information in a human-readable comment line. Learn more about this format at [paulbourke.net/dataformats/obj/](http://paulbourke.net/dataformats/obj/). MTL (or .MTL) are material library files that contain one or more material definitions, each of which includes the color, texture, and reflection map of individual materials. These are applied to the surfaces and vertices of objects. Learn more about this format at [paulbourke.net/dataformats/mtl/](http://paulbourke.net/dataformats/mtl/). - -`Entity`组件还采用`style`属性中的`transform`属性值,因此可以将对象放置在 3D 世界空间中所需的位置和方向。在我们的 MERN VR 游戏应用中,制造商将为每个`Entity`添加指向 VR 对象文件的 URL(包括`.obj`和`.mtl`对象,并指定`transform`属性值以指示 3D 对象在游戏世界中的位置和放置方式。 - -A good source of 3D objects is [https://clara.io/](https://clara.io/), with multiple file formats available for download and use. - -# VrButton - -React 360 中的`VrButton`组件将有助于为添加到游戏中的对象和`Text`按钮实现简单的按钮样式`onClick`行为。默认情况下,`VrButton`在视图中不可见,仅作为捕获事件的包装器,但其样式可以与`View`组件相同: - -```jsx - - - Click me to make something happen! - - -``` - -此组件是一个帮助器,用于管理用户跨不同输入设备的单击类型交互。触发点击事件的输入事件包括键盘上的空格键按下、鼠标左键点击和触摸屏幕。 - -# React 360 API - -除了前面讨论的 React 360 组件外,我们还将利用 React 360 提供的 API 实现诸如设置背景场景、播放音频、处理外部链接、添加样式、捕获用户视图的当前方向以及使用静态资源文件等功能。 - -# 环境 - -我们将使用`Environment`API 使用`setBackgroundImage`方法从 React 代码更改背景场景: - -```jsx -Environment.setBackgroundImage( {uri: 'http://linktopanoramaimage.jpg' } ) -``` - -此方法将当前背景图像与指定 URL 处的资源一起设置。当我们将 React 360 游戏代码与包含游戏应用后端的 MERN 堆栈集成时,我们可以使用用户提供的图像链接动态设置游戏世界图像。 - -# 本机模块 - -React 360 中的本机模块能够访问仅在主浏览器环境中可用的功能。在游戏中,我们将使用本机模块中的`AudioModule`来播放声音以响应用户活动,并使用允许访问浏览器中`window.location`的`Location`模块来处理外部链接。这些模块可在`index.js`中访问,如下所示: - -```jsx -import { - ... - NativeModules -} from 'react-360' - -const { AudioModule, Location } = NativeModules -``` - -# 音频模块 - -当用户与 3D 对象交互时,我们将根据是否可以收集对象以及游戏是否完成来播放声音。本机模块中的`AudioModule`允许向虚拟现实世界添加声音,如背景环境音频、一次性声音效果和空间化音频。在我们的游戏中,我们将使用环境音频和一次性音效 - -* **环境音频**:为了在游戏成功完成时播放一个音频 on loop 并设置情绪,我们将使用`playEnvironmental`方法,将音频文件路径作为`source`选项,将`loop`选项作为`playback`参数: - -```jsx -AudioModule.playEnvironmental({ - source: asset('happy-bot.mp3'), - loop: true -}) -``` - -* **音效**:要在用户点击 3D 对象时播放一个声音,我们将使用`playOneShot`方法,将音频文件路径作为`source`: - -```jsx -AudioModule.playOneShot({ - source: asset('clog-up.mp3'), -}) -``` - -传递给`playEnvironmental`和`playOneShot`的选项中的`source`属性采用资源文件位置加载音频。可以是`asset()`语句,也可以是`{uri: 'PATH'}`形式的资源 URL 声明。 - -# 地方 - -在我们将 React 360 代码与包含游戏应用后端的 MERN 堆栈集成后,VR 游戏将从 MERN 服务器以包含特定游戏 ID 的声明路径启动。然后,一旦用户完成游戏,他们还可以选择离开 VR 空间,并转到包含其他游戏列表的 URL。为了在 React 360 代码中处理这些传入和传出的应用链接,我们将在本机模块中使用`Location`模块。 - -`Location`模块实质上是浏览器中只读`window.location`属性返回的`Location`对象。我们将使用`Location`对象中的`replace`方法和`search`属性来实现与外部链接相关的特性。 - -* **处理外发链接**:当我们想将用户从 VR 应用引导到另一个链接时,我们可以使用`Location`中的`replace`方法: - -```jsx -Location.replace(url) -``` - -* **处理传入链接**:当 React 360 应用从外部 URL 启动,注册组件挂载后,我们可以使用`Location`中的`search`属性访问 URL 并检索其查询字符串部分: - -```jsx -componentDidMount = () => { - let queryString = Location.search - let gameId = queryString.split('?id=')[1] -} -``` - -为了将此 React 360 组件与 MERN VR 游戏集成,并动态加载游戏详细信息,我们将捕获此初始 URL 以解析查询参数中的游戏 ID,然后使用它对 MERN 应用服务器进行读取 API 调用。此实现在[第 11 章](11.html)*中详细阐述,使用 MERN*使 VR 游戏动态化。 - -# 样式表 - -React-Native 的样式表 API 也可用于 React 360,以便在一个位置定义多个样式,而不是将样式添加到单个组件: - -```jsx -const styles = StyleSheet.create({ - subView: { - width: 10, - borderColor: '#d6d7da', - }, - text: { - fontSize: '1em', - fontWeight: 'bold', - } -}) -``` - -可以根据需要将定义的样式添加到零部件: - -```jsx - - hello - -``` - -The default distance units for CSS properties, such as width and height, are in meters when mapping to 3D space in React 360, whereas the default distance units are in pixels for 2D interfaces, as in React Native. - -# VrHeadModel - -`VrHeadModel`是 React 360 中的一个实用模块,可简化获取耳机当前方向的过程。由于用户在虚拟现实空间中四处移动,当所需功能要求将对象或文本放置在用户当前方向的前面或相对于用户当前方向时,有必要知道用户当前凝视的确切位置。 - -在 MERN VR 游戏中,我们将使用它在用户视图前向用户显示游戏完成消息,无论用户最终从初始位置转向何处。 - -例如,在收集最终对象时,用户可能正在向上或向下查看,并且无论用户在哪里注视,都应弹出已完成的消息。为了实现这一点,我们将使用`VrHeadModel`中的`getHeadMatrix()`以数字数组的形式检索当前头部矩阵,并将其设置为包含游戏完成消息的`View`样式属性中`transform`属性的值。 - -# 资产 - -React 360 中的`asset()`功能允许我们检索外部资源文件,如音频和图像文件。我们将把游戏的声音音频文件放在`static_assets`文件夹中,对于添加到游戏中的每个音频,使用`asset()`进行检索: - -```jsx -AudioModule.playOneShot({ - source: asset('collect.mp3'), -}) -``` - -# 响应 360 个输入事件 - -为了使游戏界面具有交互性,我们将使用 React 360 中公开的一些输入事件处理程序。通过鼠标、键盘、触摸屏和游戏板交互,以及在 VR 耳机上点击`gaze`按钮,可以收集输入事件。我们将处理的具体输入事件是`onEnter`、`onExit`和`onClick`事件。 - -* **onEnter**:只要平台光标开始与组件相交,就会触发此事件。我们将为游戏中的 VR 对象捕获此事件,以便当平台光标进入特定对象时,对象可以开始围绕 Y 轴旋转。 -* **onExit**:只要平台光标停止与组件相交,就会触发此事件。它与`onEnter`事件具有相同的属性,我们将使用它停止旋转刚刚退出的 VR 对象。 -* **onClick**:`onClick`事件与`VrButton`组件一起使用,与`VrButton`有点击交互时触发。我们将使用它在 VR 对象上设置点击事件处理程序,并在游戏完成消息上设置点击事件处理程序,以将用户从 VR 应用重定向到包含游戏列表的链接。 - -通过本节讨论的 VR 相关概念和组件,我们已经准备好定义游戏数据细节并开始实施完整的 VR 游戏。 - -# 游戏详情 - -MERN VR 游戏中的每个游戏都将在通用数据结构中定义,React 360 应用在呈现单个游戏细节时也将遵循该数据结构。 - -# 游戏数据结构 - -游戏数据结构将保存详细信息,如游戏名称、指向游戏世界等矩形图像位置的 URL,以及包含要添加到游戏世界的每个 VR 对象详细信息的两个数组: - -* **名称**:表示游戏名称的字符串 -* **世界**:URL 指向等矩形图像的字符串,该图像托管在云存储、CDN 或存储在 MongoDB 上 -* **answerObjects**:包含玩家可以收集的 VR 对象细节的对象数组 -* **错误对象**:一组对象,包含玩家无法收集的要放置在虚拟现实世界中的其他虚拟现实对象的详细信息 - -# 虚拟现实对象的详细信息 - -`answerObjects`数组将包含可收集的 3D 对象的详细信息,`wrongObjects`数组将包含无法收集的 3D 对象的详细信息。每个对象将包含指向三维数据资源文件和`transform`样式属性值的链接。 - -# OBJ 和 MTL 链接 - -VR 对象的 3D 数据信息资源将添加到`objUrl`和`mtlUrl`键中: - -* **对象**:链接到 3D 对象的`.obj`文件 -* **mtlUrl**:链接到附带的`.mtl`文件 - -`objUrl`和`mtlUrl`链接可能指向托管在云存储、CDN 或存储在 MongoDB 上的文件。对于 MERN VR 游戏,我们假设制造商会将 URL 添加到自己托管的 OBJ、MTL 和等矩形图像文件中。 - -# 翻译价值 - -VR 对象在 3D 空间中的位置将通过以下键中的`translate`值来定义: - -* **translateX**:对象沿 X 轴的平移值 -* **平移 Y**:对象沿 Y 轴的平移值 -* **平移 Z**:对象沿 Z 轴的平移值 - -所有转换值都是以米为单位的数字。 - -# 旋转值 - -3D 对象的方向将由以下键中的`rotate`值定义: - -* **rotateX**:物体绕 X 轴的旋转值,即上下转动物体 -* **旋转**:物体绕 Y 轴的旋转值,该 Y 轴将使物体向左或向右旋转 -* **rotateZ**:物体绕 Z 轴的旋转值,使物体前后倾斜 - -所有旋转值均以数字或数字的字符串表示形式(以度为单位)。 - -# 标度值 - -`scale`值将定义 3D 对象的相对大小外观: - -**刻度**:定义所有轴上均匀刻度的数值 - -# 颜色 - -如果 MTL 文件中未提供 3D 对象的材质纹理,则颜色值可以定义对象的默认颜色。 - -**颜色**:表示 CSS 中允许的颜色值的字符串值 - -有了这个能够保存游戏及其虚拟现实对象细节的游戏数据结构,我们可以用样本数据值在 React 360 中相应地实现游戏。 - -# 静态数据与动态数据 - -在下一章中,我们将更新 React 360 代码,以便从后端数据库动态获取游戏数据。现在,我们将在这里开始开发游戏功能,使用定义的游戏数据结构将虚拟游戏数据设置为`state`。 - -# 样本数据 - -出于初始开发目的,可以将以下示例游戏数据设置为要在游戏视图中呈现的状态: - -```jsx -game: { - name: 'Space Exploration', - world: 'https://s3.amazonaws.com/mernbook/vrGame/milkyway.jpg', - answerObjects: [ - { - objUrl: 'https://s3.amazonaws.com/mernbook/vrGame/planet.obj', - mtlUrl: 'https://s3.amazonaws.com/mernbook/vrGame/planet.mtl', - translateX: -50, - translateY: 0, - translateZ: 30, - rotateX: 0, - rotateY: 0, - rotateZ: 0, - scale: 7, - color: 'white' - } - ], - wrongObjects: [ - { - objUrl: 'https://s3.amazonaws.com/mernbook/vrGame/tardis.obj', - mtlUrl: 'https://s3.amazonaws.com/mernbook/vrGame/tardis.mtl', - translateX: 0, - translateY: 0, - translateZ: 90, - rotateX: 0, - rotateY: 20, - rotateZ: 0, - scale: 1, - color: 'white' - } - ] -} -``` - -# 在 React 360 中构建游戏视图 - -我们将应用 React 360 概念,通过更新`index.js`和`client.js`中的代码,使用游戏数据结构来实现游戏功能。对于工作版本,我们将从使用上一节中的示例游戏数据初始化的状态开始。 - -`/MERNVR/index.js`: - -```jsx -export default class MERNVR extends React.Component { - - constructor() { - super() - this.state = { - game: sampleGameData - ... - } - } - -... -} -``` - -# 更新 client.js 并装载到位置 - -`client.js`中的默认代码将`index.js`中声明的挂载点附加到 React 360 应用中的默认表面,该表面是用于放置 2D UI 的圆柱形层。为了在 3D 空间中使用基于 3D 米的坐标系进行布局,我们需要安装到`Location`而不是表面。所以更新`client.js`将`renderToSurface`替换为`renderToLocation`。 - -`/MERNVR/client.js`: - -```jsx - r360.renderToLocation( - r360.createRoot('MERNVR', { /* initial props */ }), - r360.getDefaultLocation() - ) -``` - -You can also customize the initial background scene by updating the code `r360.compositor.setBackground(**r360.getAssetURL('360_world.jpg')**)` in `client.js` to use your desired image. - -# 使用样式表定义样式 - -在`index.js`中,我们将使用我们自己的 CSS 规则更新使用`StyleSheet.create`创建的默认样式,以用于游戏中的组件。 - -`/MERNVR/index.js`: - -```jsx -const styles = StyleSheet.create({ - completeMessage: { - margin: 0.1, - height: 1.5, - backgroundColor: 'green', - transform: [ {translate: [0, 0, -5] } ] - }, - congratsText: { - fontSize: 0.5, - textAlign: 'center', - marginTop: 0.2 - }, - collectedText: { - fontSize: 0.2, - textAlign: 'center' - }, - button: { - margin: 0.1, - height: 0.5, - backgroundColor: 'blue', - transform: [ { translate: [0, 0, -5] } ] - }, - buttonText: { - fontSize: 0.3, - textAlign: 'center' - } - }) -``` - -# 世界背景 - -为了设置游戏的 360 度世界背景,我们将使用`componentDidMount`内的`Environment`API 中的`setBackgroundImage`方法更新当前背景场景。 - -`/MERNVR/index.js`: - -```jsx -componentDidMount = () => { - Environment.setBackgroundImage( - {uri: this.state.game.world} - ) -} -``` - -这将用从云存储获取的示例游戏的世界图像替换 starter React 360 项目中的默认 360 背景。如果您正在编辑默认的 React 360 应用并使其运行,刷新浏览器上的`http://localhost:8081/index.html`链接应显示一个外部空间背景,可使用鼠标进行平移: - -![](img/84ae5823-42b5-4dae-b5d9-9cfb878804ee.png) - -为了生成前面的屏幕截图,默认代码中的`View`和`Text`组件也使用自定义 CSS 规则进行了更新,以在屏幕上显示此 hello 文本。 - -# 添加 3D VR 对象 - -我们将使用`Entity`组件和`answerObjects`和`wrongObjects`阵列中的示例对象详细信息向游戏世界添加 3D 对象。 - -首先,我们将把`componentDidMount`中的`answerObjects`和`wrongObjects`数组连接起来,形成一个包含所有 VR 对象的数组。 - -`/MERNVR/index.js`: - -```jsx -componentDidMount = () => { - let vrObjects = this.state.game.answerObjects.concat(this.state.game.wrongObjects) - this.setState({vrObjects: vrObjects}) - ... -} -``` - -然后在主视图中,我们将迭代`vrObjects`数组,添加包含每个对象细节的`Entity`组件。 - -`/MERNVR/index.js`: - -```jsx -{this.state.vrObjects.map((vrObject, i) => { - return ( - - ) - }) -} -``` - -将`obj`和`mtl`文件链接添加到`source`中,`transform`样式详细信息与`setModelStyles(vrObject, index)`一起应用于`Entity`组件的样式中。 - -`/MERNVR/index.js`: - -```jsx -setModelStyles = (vrObject, index) => { - return { - display: this.state.collectedList[index] ? 'none' : 'flex', - color: vrObject.color, - transform: [ - { - translateX: vrObject.translateX - }, { - translateY: vrObject.translateY - }, { - translateZ: vrObject.translateZ - }, { - scale: vrObject.scale - }, { - rotateY: vrObject.rotateY - }, { - rotateX: vrObject.rotateX - }, { - rotateZ: vrObject.rotateZ - } - ] - } - } -``` - -`display`属性允许我们根据玩家是否已经收集到某个对象来显示或隐藏该对象。 - -`translate`和`rotate`值将在整个虚拟现实世界中以所需的位置和方向渲染 3D 对象。 - -接下来,我们将进一步更新`Entity`代码,以允许用户与 3D 对象交互。 - -# 与虚拟现实对象交互 - -为了使 VR 游戏对象具有交互性,我们将使用 React 360 事件处理程序,例如`onEnter`和`onExit`与`Entity`以及`onClick`与`VrButton`添加旋转动画和游戏行为。 - -# 轮换 - -我们想添加一个功能,每当玩家聚焦于 3D 对象时,该功能就开始围绕其 Y 轴旋转 3D 对象,即平台光标开始与呈现特定 3D 对象的`Entity`相交。 - -我们将更新上一节中的`Entity`组件,以添加`onEnter`和`onExit`处理程序。 - -`/MERNVR/index.js`: - -```jsx - -``` - -该对象将在回车时开始旋转,并在平台光标退出该对象且不再处于播放器焦点时停止。 - -# 带有 requestAnimationFrame 的动画 - -在`rotate(index)`和`stopRotate()`方法中,我们将使用`requestAnimationFrame`在浏览器上实现平滑动画的旋转动画行为。 - -The `window.requestAnimationFrame()` method asks the browser to call a specified callback function to update an animation before the next repaint. With `requestAnimationFrame`, the browser optimizes the animations to make them smoother and more resource-efficient. - -使用`rotate`方法,我们将在设定的时间间隔内以稳定的速率用`requestionAnimationFrame`更新给定对象的`rotateY`变换值。 - -`/MERNVR/index.js`: - -```jsx -this.lastUpdate = Date.now() -rotate = index => event => { - const now = Date.now() - const diff = now - this.lastUpdate - const vrObjects = this.state.vrObjects - vrObjects[index].rotateY = vrObjects[index].rotateY + diff / 200 - this.lastUpdate = now - this.setState({vrObjects: vrObjects}) - this.requestID = requestAnimationFrame(this.rotate(index)) -} -``` - -`requestAnimationFrame`将`rotate`方法作为一个递归回调函数,然后执行它,用新值重新绘制旋转动画的每一帧,然后更新屏幕上的动画。 - -`requestAnimateFrame`方法返回一个`requestID`,我们将在`stopRotate`中使用它来取消`stopRotate`方法中的动画。 - -`/MERNVR/index.js`: - -```jsx -stopRotate = () => { - if (this.requestID) { - cancelAnimationFrame(this.requestID) - this.requestID = null - } -} -``` - -这将实现仅当 3D 对象处于查看器焦点时才设置其动画的功能。如下图所示,3D 魔方在聚焦时围绕其 Y 轴顺时针旋转: - -![](img/964de90f-5066-4af4-a817-058f9b65989a.png) - -Though not covered here, it is also worth exploring the React 360 Animated library, which can be used to compose different types of animations. Core components can be animated natively with this library, and it is possible to make other components animatable using `createAnimatedComponent()`. This library was originally implemented from React Native, and to learn more you can refer to the React Native documentation. - -# 单击三维对象 - -为了注册添加到游戏中的每个 3D 对象的点击行为,我们需要用一个可以调用`onClick`处理程序的`VrButton`组件包装`Entity`组件。 - -我们将更新`vrObjects`数组迭代代码中添加的`Entity`组件,用`VrButton`组件包装它。点击时`VrButton`会调用`collectItem`方法,并将当前对象的详细信息传递给它。 - -`/MERNVR/index.js`: - -```jsx - - - -``` - -点击 3D 对象时,`collectItem`方法需要针对游戏功能执行以下动作: - -* 检查点击的对象是`answerObject`还是`wrongObject` -* 根据对象类型,播放关联的声音 -* 如果该对象是一个`answerObject`,则应将其收集并从视图中消失 - * 更新收集的对象列表 -* 检查此点击是否成功收集了`answerObject`的所有实例 - * 如果是,则向玩家显示游戏已完成消息,并播放游戏已完成的声音 - -因此,`collectItem`方法将具有以下结构和步骤: - -```jsx -collectItem = vrObject => event => { - if (vrObject is an answerObject) { - ... update collected list ... - ... play sound for correct object collected ... - if (all answer objects collected) { - ... show game completed message in front of user ... - ... play sound for game completed ... - } - } else { - ... play sound for wrong object clicked ... - } -} -``` - -接下来,我们将研究这些步骤的实现。 - -# 在单击时收集正确的对象 - -当用户单击 3D 对象时,我们需要首先检查单击的对象是否为应答对象。如果是,此*收集的*对象将隐藏在视图中,收集的对象列表将与总数一起更新,以跟踪用户在游戏中的进度。 - -为了检查点击的 VR 对象是否为`answerObject`,我们将使用`indexOf`方法在`answerObjects`数组中查找匹配项: - -```jsx -let match = this.state.game.answerObjects.indexOf(vrObject) -``` - -如果`vrObject`是`answerObject`,则`indexOf`返回匹配对象的数组索引,否则如果未找到匹配项,则返回`-1`。 - -为了跟踪游戏中收集的物品,我们还将在`collectedList`中维护一个布尔值数组,并在`collectedNum`中维护到目前为止收集的物品总数: - -```jsx -let updateCollectedList = this.state.collectedList -let updateCollectedNum = this.state.collectedNum + 1 -updateCollectedList[match] = true -this.setState({collectedList: updateCollectedList, - collectedNum: updateCollectedNum}) -``` - -使用`collectedList`数组,我们还将确定哪个`Entity`组件应该从视图中隐藏,因为关联的对象已被收集。相关`Entity`的`display`样式属性将根据`collectedList`数组中相应索引的布尔值进行设置,同时为`Entity`设置样式使用`setModelStyles`方法的组件,如前面的*添加 3D VR 对象*部分所示: - -```jsx -display: this.state.collectedList[index] ? 'none' : 'flex' -``` - -下图中,宝箱是`answerObject`可以点击采集,而花盆是`wrongObject`不能采集: - -![](img/a24e207b-ab10-4470-a0e4-4aa47c33fc0a.png) - -当点击宝箱时,随着`collectedList`的更新,宝箱从视图中消失,我们也使用`AudioModule.playOneShot`播放收藏音效: - -```jsx -AudioModule.playOneShot({ - source: asset('collect.mp3'), -}) -``` - -但当点击花盆时,它被识别为错误的对象,我们播放另一种声音效果,表明它无法收集: - -```jsx -AudioModule.playOneShot({ - source: asset('clog-up.mp3'), -}) -``` - -由于花盆被识别为错误的对象,`collectedList`未更新,并保留在屏幕上,如以下屏幕截图所示: - -![](img/c8f40ddb-babd-44cf-8270-3966e0440f1d.png) - -单击对象时执行所有这些步骤的`collectItem`方法中的完整代码如下所示。 - -`/MERNVR/index.js`: - -```jsx - collectItem = vrObject => event => { - let match = this.state.game.answerObjects.indexOf(vrObject) - if (match != -1) { - let updateCollectedList = this.state.collectedList - let updateCollectedNum = this.state.collectedNum + 1 - updateCollectedList[match] = true - this.checkGameCompleteStatus(updateCollectedNum) - AudioModule.playOneShot({ - source: asset('collect.mp3'), - }) - this.setState({collectedList: updateCollectedList, collectedNum: updateCollectedNum}) - } else { - AudioModule.playOneShot({ - source: asset('clog-up.mp3'), - }) - } - } -``` - -收集点击的对象后,我们还将检查是否收集了所有的`answerObjects`,并且游戏使用`checkGameCompleteStatus`方法完成,如下一节所述。 - -# 游戏完成状态 - -每次收集到一个`answerObject`时,我们会通过调用`checkGameCompleteStatus`来检查收集到的物品总数是否等于`answerObjects`数组中的物品总数,以确定游戏是否完成。 - -`/MERNVR/index.js`: - -```jsx - if (collectedTotal == this.state.game.answerObjects.length) { - AudioModule.playEnvironmental({ - source: asset('happy-bot.mp3'), - loop: true - }) - this.setState({hide: 'flex', hmMatrix: VrHeadModel.getHeadMatrix()}) - } -``` - -如果游戏确实完成,我们将执行以下操作: - -* 使用`AudioModule.playEnvironmental`播放游戏完成后的音频 -* 使用`VrHeadModel`获取当前`headMatrix`值,以便将其设置为包含游戏完成消息的`View`组件的变换矩阵值 -* 将消息`View`的`display`样式属性设置为`flex`,以便消息呈现给查看者 - -包含祝贺玩家完成游戏消息的`View`组件将添加到父`View`组件中,如下所示。 - -`/MERNVR/index.js`: - -```jsx - - - Congratulations! - - You have collected all items in {this.state.game.name} - - - - - Play another game - - - -``` - -对`setGameCompletedStyle()`方法的调用将使用更新的`display`值和`transform`矩阵值设置消息`View`的样式。 - -`/MERNVR/index.js`: - -```jsx -setGameCompletedStyle = () => { - return { - position: 'absolute', - display: this.state.hide, - layoutOrigin: [0.5, 0.5], - width: 6, - transform: [{translate: [0, 0, 0]}, {matrix: this.state.hmMatrix}] - } -} -``` - -这将使`View`与完成消息一起呈现在用户当前视图的中心,无论他们在 360 度虚拟现实世界中是向上看、向下看、向后看还是向前看: - -![](img/895c5e99-6682-46fc-8b56-062dfd0f2fbf.png) - -`View`消息中的最后文本将充当按钮,因为我们将`View`包装在`VrButton`组件中,该组件在单击时调用`exitGame`方法。 - -`/MERNVR/index.js`: - -```jsx -exitGame = () => { - Location.replace('/') -} -``` - -`exitGame`方法将使用`Location.replace`方法将用户重定向到可能包含游戏列表的外部 URL。 - -`replace`方法可以传递任何有效的 URL,一旦此 React 360 游戏代码与[第 11 章](11.html)中的 MERN VR 游戏应用集成,*使用 MERN*使 VR 游戏动态,`replace('/')`将用户带到应用的主页。 - -# 捆绑生产并与 MERN 集成 - -现在,我们已经用示例游戏数据实现了 VR 游戏的功能,我们可以将其准备好用于生产,并将其添加到我们的 MERN base 应用中,以了解如何将 VR 添加到现有的 web 应用中。 - -React 360 工具提供了一个脚本,可以将所有 React 360 应用代码捆绑到几个文件中,我们可以将这些文件放在 MERN web 服务器上,并作为指定路径的内容。 - -# 绑定 360 个文件 - -要创建捆绑文件,我们可以从 React 360 项目目录运行以下命令: - -```jsx -npm run bundle -``` - -这将在名为`build`的文件夹中生成 React 360 应用文件的编译版本。编译的捆绑文件为`client.bundle.js`和`index.bundle.js`。除`index.html`和`static-img/`文件夹外,这两个文件构成了整个 React 360 应用的生产版本: - -```jsx --- static_img/ - --- index.html - --- index.bundle.js - --- client.bundle.js -``` - -# 与 MERN 应用集成 - -我们需要将这三个文件和`static_assets`文件夹添加到我们的 MERN 应用中,然后确保捆绑文件引用在`index.html`中是准确的,最后在 Express app 中的指定路径加载`index.html`。 - -# 添加生产文件 - -考虑到 MERN skeleton 应用中的文件夹结构,我们将把`static_assets`文件夹和捆绑包文件添加到`dist/`文件夹中,以保持我们的 MERN 代码井然有序,并使所有捆绑包位于同一位置。`index.html`文件将被放置在`server`文件夹中名为`vr`的新文件夹中: - -```jsx --- ... --- client/ --- dist/ - --- static_img/ - --- ... - --- client.bundle.js - --- index.bundle.js --- ... --- server/ - --- ... - --- vr/ - ---- index.html --- ... -``` - -# 更新 index.html 中的引用 - -如图所示,生成的`index.html`文件引用了捆绑文件,希望这些文件位于同一文件夹中: - -```jsx - - - MERNVR - - - - - -
- - - - -``` - -我们需要更新`index.html`以参考`client.bundle.js`、`index.bundle.js`和`static_assets`文件夹的正确位置。 - -首先,更新对`client.bundle.js`的引用,如下所示: - -```jsx - -``` - -然后,使用对`index.bundle.js`的正确引用更新`React360.init`,并将`assetRoot`设置为`static_assets`文件夹的正确位置: - -```jsx -React360.init( - './../dist/index.bundle.js', - document.getElementById('container'), - { assetRoot: '/dist/static_img/' } - ) -``` - -当我们使用`asset()`在组件中设置资源时,`assetRoot`将告诉 React 360 在哪里查找资产文件。 - -现在,如果我们在 MERN 应用中设置了一条快速路线以返回响应中的`index.html`文件,那么在浏览器中访问路线将呈现 React 360 游戏。 - -# 尝试集成 - -为了测试这种集成,我们可以设置一个示例路由,如下所示: - -```jsx -router.route('/game/play') - .get((req, res) => { - res.sendFile(process.cwd()+'/server/vr/index.html') -}) -``` - -然后运行 MERN 服务器,在浏览器`localhost:3000/game/play`处打开路由。这将从我们的 MERN 应用中呈现本章中实现的 React 360 游戏。 - -# 总结 - -在本章中,我们使用 React 360 开发了一款基于 web 的虚拟现实游戏,可以轻松地集成到 MERN 应用中。 - -我们首先为游戏定义简单的虚拟现实功能,然后设置 React 360 进行开发,并研究了关键的虚拟现实概念,如 360 度虚拟现实世界中的等矩形全景图像、三维位置和坐标系。我们探索了实现游戏功能所需的 React 360 组件和 API,包括`View`、`Text`、`Entity`、`VrButton`等组件,以及`Environment`、`VrHeadModel`和`NativeModules`API。 - -最后,我们更新了 starter React 360 项目中的代码,用示例游戏数据实现了游戏,然后捆绑了代码文件,并讨论了如何将这些编译后的文件添加到现有的 MERN 应用中。 - -在下一章中,我们将开发 MERN VR 游戏应用,包括游戏数据库和 API,这样我们就可以通过从 MongoDB 中的游戏集合获取数据,使本章中开发的游戏动态化。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/11.md b/docs/full-stk-react-proj/11.md deleted file mode 100644 index da10019..0000000 --- a/docs/full-stk-react-proj/11.md +++ /dev/null @@ -1,1352 +0,0 @@ -# 十一、使用 MERN 使虚拟现实游戏动态化 - -在本章中,我们将扩展 MERN skeleton 应用以构建 MERN VR 游戏应用,并使用它将前一章中开发的静态 React 360 游戏动态化,方法是将示例游戏数据替换为直接从 MERN 服务器获取的游戏细节。 - -为了使 MERN VR 游戏成为一款完整、动态的游戏应用,我们将实现以下功能: - -* MongoDB 中存储游戏细节的游戏模型模式 -* 用于游戏积垢操作的 API -* 游戏创建、编辑、列表和删除的反应视图 -* 更新 React 360 游戏以从 API 获取数据 -* 用动态游戏数据加载虚拟现实游戏 - -# 动态梅恩虚拟现实游戏 - -MERN VR Game 上的注册用户将能够制作和修改自己的游戏,方法是为游戏世界提供等矩形图像和 VR 对象资源,包括放置在游戏世界中的每个对象的变换属性值。该应用的任何访问者都将能够浏览制造商添加的所有游戏,并玩任何游戏来查找和收集游戏世界中与每个游戏的线索或描述相关的 3D 对象: - -![](img/7805f18a-fdd7-43b7-acd5-6651f78976de.png) - -The code for the complete MERN VR Game application is available on GitHub at [github.com/shamahoque/mern-vrgame](https://github.com/shamahoque/mern-vrgame). You can clone this code and run the application as you go through the code explanations in the rest of this chapter.  - -与创建、编辑和列出虚拟现实游戏相关的功能所需的视图将通过扩展和修改 MERN skeleton 应用中的现有 React 组件来开发。下图中的组件树显示了构成本章开发的 MERN 虚拟现实游戏前端的所有自定义 React 组件: - -![](img/a646f3f9-7903-4376-8e5f-69707ef64ea7.jpg) - -# 博弈模型 - -在[第 10 章](10.html)*开发基于网络的虚拟现实游戏*中,*游戏数据结构*部分列出了每个游戏所需的细节,以实现为游戏定义的清道夫狩猎功能。我们将根据这些关于游戏的具体细节、虚拟现实对象以及游戏制作者的参考来设计游戏模式。 - -# 博弈模式 - -在`game.model.js`中定义的游戏模型的 Mongoose 模式中,我们将为 - -* 游戏名称 -* 世界图像 URL -* 线索文本 -* 一个数组,包含要添加为可收集答案对象的 VR 对象的详细信息 -* 包含 VR 对象详细信息的数组,这些对象是错误的对象,无法收集 -* 指示游戏创建和更新时间的时间戳 -* 对制作游戏的用户的引用 - -`GameSchema`的定义如下。 - -`mern-vrgame/server/models/game.model.js`: - -```jsx -const GameSchema = new mongoose.Schema({ - name: { - type: String, - trim: true, - required: 'Name is required' - }, - world: { - type: String, trim: true, - required: 'World image is required' - }, - clue: { - type: String, - trim: true - }, - answerObjects: [VRObjectSchema], - wrongObjects: [VRObjectSchema], - updated: Date, - created: { - type: Date, - default: Date.now - }, - maker: {type: mongoose.Schema.ObjectId, ref: 'User'} -}) -``` - -# 虚拟对象模式 - -游戏模式中的`answerObjects`和`wrongObjects`字段都是 VRObject 文档的数组,VRObject Mongoose 模式将分别定义用于存储 OBJ 文件和 MTL 文件 URL 的字段,以及每个 VR 对象的 React 360`transform`值、`scale`值和`color`值。 - -`mern-vrgame/server/models/game.model.js`: - -```jsx -const VRObjectSchema = new mongoose.Schema({ - objUrl: { - type: String, trim: true, - required: 'ObJ file is required' - }, - mtlUrl: { - type: String, trim: true, - required: 'MTL file is required' - }, - translateX: {type: Number, default: 0}, - translateY: {type: Number, default: 0}, - translateZ: {type: Number, default: 0}, - rotateX: {type: Number, default: 0}, - rotateY: {type: Number, default: 0}, - rotateZ: {type: Number, default: 0}, - scale: {type: Number, default: 1}, - color: {type: String, default: 'white'} -}) -``` - -当一个新的游戏文档保存到数据库中时,`answerObjects`和`wrongObjects`数组将填充符合此模式定义的 VRObject 文档。 - -# 游戏模式中的数组长度验证 - -游戏文档中的`answerObjects`和`wrongObjects`数组在保存到游戏集合中时,每个数组中必须至少包含一个 VRObject 文档。为了将最小数组长度的验证添加到游戏模式中,我们将在`GameSchema`中的`answerObjects`和`wrongObjects`路径中添加以下自定义验证检查。 - -`mern-vrgame/server/models/game.model.js`: - -```jsx -GameSchema.path('answerObjects').validate(function(v) { - if (v.length == 0) { - this.invalidate('answerObjects', - 'Must add alteast one VR object to collect') - } -}, null) -``` - -```jsx -GameSchema.path('wrongObjects').validate(function(v) { - if (v.length == 0) { - this.invalidate('wrongObjects', - 'Must add alteast one other VR object') - } -}, null) -``` - -这些模式定义将满足根据 MERN VR 游戏规范开发动态 VR 游戏的所有要求 - -# 游戏 API - -MERN VR 游戏的后端将公开一组 CRUD API,用于从数据库中创建、编辑、读取、列出和删除游戏,这些 API 可用于应用的前端,包括 React 360 游戏实现中的 fetch 调用。 - -# 创建 API - -登录应用的用户将能够使用`create`API 在数据库中创建新游戏。 - -# 路线 - -在后端,我们将在`game.routes.js`中添加`POST`路由,验证当前用户是否已登录和授权,然后使用请求中传递的游戏数据创建新游戏。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/api/games/by/:userId') - .post(authCtrl.requireSignin,authCtrl.hasAuthorization, gameCtrl.create) -``` - -为了处理`:userId`参数并从数据库中检索相关用户,我们将使用用户控制器中的`userByID`方法。我们还将在游戏路线中添加以下内容,因此用户可以在`request`对象中作为`profile`使用。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.param('userId', userCtrl.userByID) -``` - -`game.routes.js`文件将与`user.routes`文件非常相似,要在 Express app 中加载这些新路由,我们需要在`express.js`中装载游戏路由,就像我们在验证和用户路由中所做的那样。 - -`mern-vrgame/server/express.js`: - -```jsx -app.use('/', gameRoutes) -``` - -# 控制器 - -`create`控制器方法在`'/api/games/by/:userId'`接收到 POST 请求时执行,请求主体包含新的游戏数据。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const create = (req, res, next) => { - const game = new Game(req.body) - game.maker= req.profile - game.save((err, result) => { - if(err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.status(200).json(result) - }) -} -``` - -在这个`create`方法中,使用游戏模式和从客户端传入请求体的数据创建一个新的游戏文档。将用户引用设置为游戏制作人后,此文档保存在`Game`集合中。 - -# 取来 - -在前端,我们将在`api-game.js`中增加相应的`fetch`方法,通过传递从登录用户采集的表单数据,向`create`API 发出`POST`请求。 - -`mern-vrgame/client/game/api-game.js`: - -```jsx -const create = (params, credentials, game) => { - return fetch('/api/games/by/'+ params.userId, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(game) - }) - .then((response) => { - return response.json(); - }).catch((err) => console.log(err)) -} -``` - -# 列表 API - -可以使用列表 API 从后端获取`Game`集合中所有游戏的列表。 - -# 路线 - -我们将向游戏路径添加一个 GET 路径,以检索数据库中存储的所有游戏。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/api/games') - .get(gameCtrl.list) -``` - -对`/api/games`的`GET`请求将执行`list`控制器方法。 - -# 控制器 - -`list`控制器方法将查询数据库中的`Game`集合,将响应中的所有游戏返回给客户端。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const list = (req, res) => { - Game.find({}).populate('maker', '_id name') - .sort('-created').exec((err, games) => { - if(err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(games) - - }) -} -``` - -# 取来 - -在前端,为了使用这个列表 API 获取游戏,我们将在`api-game.js`中设置一个`fetch`方法。 - -`mern-vrgame/client/game/api-game.js`: - -```jsx -const list = () => { - return fetch('/api/games', { - method: 'GET', - }).then(response => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -# 按制造商 API 列出 - -该应用还将允许我们获取特定用户使用 list by maker API 制作的游戏。 - -# 路线 - -在游戏路线中,我们将添加一个`GET`路线来检索特定用户制作的游戏。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/api/games/by/:userId') - .get(gameCtrl.listByMaker) -``` - -对该路由的`GET`请求将在游戏控制器中执行`listByMaker`方法。 - -# 控制器 - -`listByMaker`控制器方法将查询数据库中的游戏集合,以获得匹配的游戏。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const listByMaker = (req, res) => { - Game.find({maker: req.profile._id}, (err, games) => { - if(err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(games) - }).populate('maker', '_id name') -} -``` - -在对游戏集合的查询中,我们找到了`maker`字段与`req.profile`中指定的用户匹配的所有游戏。 - -# 取来 - -在前端,为了通过 maker API 为特定用户获取此列表中的游戏,我们将在`api-game.js`中添加`fetch`方法。 - -`mern-vrgame/client/game/api-game.js`: - -```jsx -const listByMaker = (params) => { - return fetch('/api/games/by/'+params.userId, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 读取 API - -个人游戏数据将使用`'/api/game/:gameId'`上的`read`API 从数据库中检索。 - -# 路线 - -在后端,我们将添加一个`GET`路由,该路由使用 ID 查询`Game`集合,并在响应中返回游戏。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/api/game/:gameId') - .get(gameCtrl.read) -``` - -首先处理路由 URL 中的`:gameId`参数,从数据库中检索单个游戏。因此,我们还将在游戏路线中添加以下内容: - -```jsx -router.param('gameId', gameCtrl.gameByID) -``` - -# 控制器 - -读取 API 请求中的`:gameId`参数将调用`gameByID`控制器方法,类似于`userByID`控制器方法。它将从数据库中检索游戏,并将其附加到将在`next`方法中使用的`request`对象。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const gameByID = (req, res, next, id) => { - Game.findById(id).populate('maker', '_id name').exec((err, game) => { - if (err || !game) - return res.status('400').json({ - error: "Game not found" - }) - req.game = game - next() - }) -} -``` - -`next`方法,在本例中是`read`控制器方法,只是在响应中向客户端返回这个`game`对象。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const read = (req, res) => { - return res.json(req.game) -} -``` - -# 取来 - -在前端代码中,我们将添加一个`fetch`方法,利用此读取 API 根据单个游戏的 ID 检索其详细信息。 - -`mern-vrgame/client/game/api-game.js`: - -```jsx -const read = (params, credentials) => { - return fetch('/api/game/' + params.gameId, { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -``` - -此`read`API 将用于获取游戏详细信息的 React 视图和 React 360 游戏视图,后者将呈现游戏界面。 - -# 编辑 API - -已登录的授权用户以及特定游戏的制造商将能够使用`edit`API 编辑该游戏的详细信息。 - -# 路线 - -在后端,我们将添加一个`PUT`路由,允许授权用户编辑他们的游戏之一。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/api/games/:gameId') - .put(authCtrl.requireSignin, gameCtrl.isMaker, gameCtrl.update) -``` - -对`'/api/games/:gameId'`的 PUT 请求将首先执行`gameByID`控制器方法,以检索特定游戏的详细信息。还将调用`requireSignin`auth controller 方法以确保当前用户已登录。然后`isMaker`控制器方法将在最终运行游戏`update`控制器方法修改数据库中的游戏之前,确定当前用户是否是该特定游戏的制作者。 - -# 控制器 - -`isMaker`控制器方法确保登录用户实际上是正在编辑的游戏的制作者。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const isMaker = (req, res, next) => { - let isMaker = req.game && req.auth && req.game.maker._id == req.auth._id - if(!isMaker){ - return res.status('403').json({ - error: "User is not authorized" - }) - } - next() -} -``` - -游戏控制器中的`update`方法将现有游戏细节和请求主体中接收到的表单数据合并更改,并将更新后的游戏保存到数据库中的游戏集合中。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const update = (req, res) => { - let game = req.game - game = _.extend(game, req.body) - game.updated = Date.now() - game.save((err) => { - if(err) { - return res.status(400).send({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(game) - }) -} -``` - -# 取来 - -在视图中使用`fetch`方法调用`edit`API,该方法获取表单数据并将其与请求一起发送到后端以及用户凭据。 - -`mern-vrgame/client/game/api-game.js`: - -```jsx -const update = (params, credentials, game) => { - return fetch('/api/games/' + params.gameId, { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - }, - body: JSON.stringify(game) - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -# 删除 API - -经过身份验证和授权的用户将能够删除他们使用`delete`游戏 API 在应用上制作的任何游戏。 - -# 路线 - -在后端,我们将添加一条`DELETE`路线,允许授权制造商删除他们自己的一款游戏。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/api/games/:gameId') - .delete(authCtrl.requireSignin, gameCtrl.isMaker, gameCtrl.remove) -``` - -在`'api/games/:gameId'`接收到删除请求后,服务器上控制器方法的执行流程类似于编辑 API,最后调用的是`remove`控制器方法,而不是`update`。 - -# 控制器 - -`remove`控制器方法在`'/api/games/:gameId'`收到删除请求且已验证当前用户是给定游戏的原始制作者时,从数据库中删除指定游戏。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const remove = (req, res) => { - let game = req.game - game.remove((err, deletedGame) => { - if(err) { - return res.status(400).json({ - error: errorHandler.getErrorMessage(err) - }) - } - res.json(deletedGame) - }) -} -``` - -# 取来 - -我们将在`api-game.js`中添加相应的`remove`方法,对删除 API 发出`delete`取数请求。 - -`mern-vrgame/client/game/api-game.js`: - -```jsx -const remove = (params, credentials) => { - return fetch('/api/games/' + params.gameId, { - method: 'DELETE', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + credentials.t - } - }).then((response) => { - return response.json() - }).catch((err) => { - console.log(err) - }) -} -``` - -有了这些游戏 API,我们可以为应用构建 React 视图,还可以更新 React 360 游戏视图代码以获取和呈现动态游戏细节。 - -# 创建和编辑游戏 - -在 MERN VR Game 上注册的用户将能够制作新游戏并在应用中修改这些游戏。我们将添加 React 组件,允许用户修改每个游戏的游戏细节和 VR 对象细节。 - -# 制作新游戏 - -当用户登录到应用中时,他们将在菜单上看到一个 MAKE GAME 链接,该链接将引导他们到`NewGame`组件,该组件包含创建新游戏的表单。 - -# 更新菜单 - -我们将更新导航菜单以添加“制作游戏”按钮,如以下屏幕截图所示: - -![](img/c554d5ac-420f-4762-8084-6a88044afb1f.png) - -在`Menu`组件中,我们将把`Link`添加到`NewGame`组件的路由中,就在 MY PROFILE 链接之前,在仅当用户通过身份验证时才呈现的部分中。 - -`mern-vrgame/client/core/Menu.js`: - -```jsx - - - -``` - -# 新游戏组件 - -`NewGame`组件使用`GameForm`组件呈现用户将填写的表单元素,以创建新游戏: - -![](img/a825261e-e98e-497a-b21e-17a07727bdfd.png) - -`GameForm`包含所有表单字段,它采用用户提交表单时应执行的`onSubmit`方法,作为`NewGame`组件的道具,以及任何服务器返回的错误消息。 - -`mern-vrgame/client/game/NewGame.js`: - -```jsx - -``` - -`clickSubmit`方法使用`api-game.js`中的创建`fetch`方法向`create`API 发出 POST 请求,请求中包含游戏表单数据和用户详细信息。 - -`mern-vrgame/client/game/NewGame.js`: - -```jsx - clickSubmit = game => event => { - const jwt = auth.isAuthenticated() - create({ - userId: jwt.user._id - }, { - t: jwt.token - }, game).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({error: '', redirect: true}) - } - }) - } -``` - -我们将在`MainRouter`中添加一个`PrivateRoute`,这样`NewGame`组件将以`/game/new`路径加载到浏览器中。 - -`mern-vrgame/client/MainRouter.js`: - -```jsx - -``` - -# 编辑游戏 - -用户将能够使用`EditGame`组件编辑他们制作的游戏,该组件将呈现预先填充了现有游戏详细信息的游戏表单字段。 - -# 编辑游戏组件 - -就像在`NewGame`组件中一样,`EditGame`组件也会使用`GameForm`组件来渲染表单元素,但这次字段会显示游戏字段的当前值,用户可以更新这些值: - -![](img/2742c2b7-045f-4230-8734-f6eb3b5fc639.png) - -在`EditGame`组件的情况下,`GameForm`将以给定游戏的 ID 作为道具,以便除`onSubmit`方法和服务器生成的错误消息(如果有)外,还可以获取游戏详细信息。 - -`mern-vrgame/client/game/EditGame.js`: - -```jsx - -``` - -编辑表单的`clickSubmit`方法将使用`api-game.js`中的`update`获取方法向编辑 API 发出 PUT 请求,请求中包含表单数据和用户详细信息。 - -`mern-vrgame/client/game/EditGame.js`: - -```jsx -clickSubmit = game => event => { - const jwt = auth.isAuthenticated() - update({ - gameId: this.match.params.gameId - }, { - t: jwt.token - }, game).then((data) => { - if (data.error) { - this.setState({error: data.error}) - } else { - this.setState({error: '', redirect: true}) - } - }) - } -``` - -`EditGame`组件将以`MainRouter`中的`PrivateRoute`中声明的`/game/edit/:gameId`路径加载到浏览器中。 - -`mern-vrgame/client/MainRouter.js`: - -```jsx - -``` - -# 游戏形式组件 - -`NewGame`和`EditGame`组件中使用的`GameForm`组件包含允许用户输入单个游戏的游戏详细信息和 VR 对象详细信息的元素。它可以从空白游戏对象开始,也可以在`componentDidMount`中加载现有游戏。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx -state = { - game: {name: '', clue:'', world:'', answerObjects:[], wrongObjects:[]}, - redirect: false, - readError: '' - } -``` - -如果`GameForm`组件从父组件(如`EditGame`组件)接收到`gameId`道具,则它将使用读取 API 检索游戏的详细信息,并将其设置为要在表单视图中呈现的状态。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx -componentDidMount = () => { - if(this.props.gameId){ - read({gameId: this.props.gameId}).then((data) => { - if (data.error) { - this.setState({readError: data.error}) - } else { - this.setState({game: data}) - } - }) - } -} -``` - -`GameForm`组件中的表单视图基本上有两个部分,一部分以简单的游戏细节(如名称、世界图像链接和线索文本)作为输入,另一部分允许用户向应答对象数组或错误对象数组添加可变数量的 VR 对象。 - -# 输入简单的游戏细节 - -简单的游戏细节部分主要是使用 Material UI`TextField`组件添加的文本输入,并将更改处理方法传递给`onChange`。 - -# 表格标题 - -表单标题将为`New Game`或`Edit Game`,具体取决于现有游戏 ID 是否作为道具传递给`GameForm`。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx - - {this.props.gameId? 'Edit': 'New'} Game - -``` - -# 游戏世界形象 - -我们将在最顶部的`img`元素中呈现背景图像 URL,以向用户显示他们作为游戏世界图像 URL 添加的图像。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx - - -``` - -# 游戏名称 - -游戏名称将添加到默认类型为`text`的单个`TextField`中。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx - -``` - -# 线索文本 - -线索文本将添加到多行`TextField`组件中。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx - -``` - -# 处理输入 - -所有输入更改将通过`handleChange`方法处理,该方法将使用用户输入更新处于状态的游戏值。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx -handleChange = name => event => { - const newGame = this.state.game - newGame[name] = event.target.value - this.setState({game: newGame}) -} -``` - -# 修改 VR 对象的数组 - -为了允许用户修改他们希望添加到 VR 游戏中的`answerObjects`和`wrongObjects`数组,`GameForm`将迭代每个数组,并为每个对象呈现一个`VRObjectForm`组件。这样,就可以从`GameForm`组件中添加、删除和修改 VR 对象: - -![](img/c9d59097-561d-4b45-97e0-aab65d9c09f7.png) - -# 迭代并呈现对象详细信息表单 - -使用 Material UI`ExpansionPanel`组件,我们将添加前面看到的表单界面,为给定游戏中的每种类型的 VR 对象数组创建一个可修改的 VR 对象细节数组。 - -在`ExpansionPanelDetails`组件内部,我们将迭代`answerObjects`数组或`wrongObjects`数组,为每个 VR 对象渲染一个`VRObjectForm`组件。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx - - }> - VR Objects to collect - - { - this.state.game.answerObjects.map((item, i) => { - return
- -
})} - -
-
-``` - -每个`VRObjectForm`将以`vrObject`本身、数组中当前的`index`、对象数组的类型以及通过更改细节或删除`VRObjectForm`组件中的对象来修改数组细节时更新`GameForm`中状态的两种方法作为道具。 - -# 向数组中添加新对象 - -添加对象按钮将允许用户添加新的`VRObjectForm`组件,以获取新 VR 对象的详细信息。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx -addObject = name => event => { - const newGame = this.state.game - newGame[name].push({}) - this.setState({game: newGame}) -} -``` - -这基本上只需向正在迭代的数组中添加一个空对象,并使用 name 值中指定的数组类型调用`addObject`方法。 - -# 从数组中删除对象 - -每个`VRObjectForm`组件也可以被删除,以从给定数组中移除对象。`GameForm`会将`removeObject`方法作为道具传递给`VRObjectForm`组件,这样当用户点击`delete`特定`VRObjectForm`时,该数组可以在状态下更新。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx -removeObject = (type, index) => event => { - const newGame = this.state.game - newGame[type].splice(index, 1) - this.setState({game: newGame}) -} -``` - -通过从名称中指定数组类型的数组中按给定的`index`进行切片,将对象从数组中移除。 - -# 处理对象细节更改 - -当用户更改任何`VRObjectForm`字段中的输入值时,VR 对象详细信息将在`GameForm`组件状态下更新。要注册此更新,`GameForm`将`handleObjectChange`方法传递给`VRObjectForm`组件。 - -`mern-vrgame/client/game/GameForm.js`: - -```jsx -handleObjectChange = (index, type, name, val) => { - var newGame = this.state.game - newGame[type][index][name] = val - this.setState({game: newGame}) -} -``` - -`handleObjectChange`方法用给定的`type`更新数组中`index`处的特定对象的字段值,从而反映在`GameForm`状态下存储的游戏对象中。 - -# VRObjectForm 组件 - -`VRObjectForm`组件将呈现输入字段,以修改单个 VR 对象的详细信息,并将其添加到`GameForm`组件中游戏的`answerObjects`和`wrongObjects`数组中: - -![](img/101c7142-c187-4ade-a62b-416575acbfef.png) - -可以从空白 VR 对象开始,也可以在`componentDidMount`中加载现有 VR 对象的详细信息。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx -state = { - objUrl: '', mtlUrl: '', - translateX: 0, translateY: 0, translateZ: 0, - rotateX: 0, rotateY: 0, rotateZ: 0, - scale: 1, color:'white' -} -``` - -在`componentDidMount`中,状态将设置为`GameForm`组件作为道具传递的`vrObject`的详细信息。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx -componentDidMount = () => { - if(this.props.vrObject && - Object.keys(this.props.vrObject).length != 0){ - const vrObject = this.props.vrObject - this.setState({ - objUrl: vrObject.objUrl, - mtlUrl: vrObject.mtlUrl, - translateX: Number(vrObject.translateX), - translateY: Number(vrObject.translateY), - translateZ: Number(vrObject.translateZ), - rotateX: Number(vrObject.rotateX), - rotateY: Number(vrObject.rotateY), - rotateZ: Number(vrObject.rotateZ), - scale: Number(vrObject.scale), - color:vrObject.color - }) - } -} -``` - -修改这些值的输入字段将使用物料 UI`TextField`组件添加。 - -# 三维对象文件输入 - -将使用`TextField`组件为每个 VR 对象添加 OBJ 和 MTL 文件链接,作为文本输入。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx -
- -``` - -# 翻译值输入 - -VR 对象在 X、Y 和 Z 轴上的平移值将输入到`number`类型的`TextField`组件中。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx - - - -``` - -# 旋转值输入 - -围绕 X、Y 和 Z 轴的 VR 对象的`rotate`值将输入到`number`类型的`TextField`组件中。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx - - - -``` - -# 刻度值输入 - -VR 对象的`scale`值将输入`number`类型的`TextField`组件中。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx - -``` - -# 对象颜色输入 - -VR 对象的颜色值将输入到`text`类型的`TextField`组件中: - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx - -``` - -# 删除对象按钮 - -`VRObjectForm`将包含一个`Delete`按钮,该按钮将执行`GameForm`道具表单中接收到的`removeObject`方法: - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx - -``` - -`removeObject`方法将获取对象数组类型和数组索引位置的值,以在`GameForm`状态下从相关 VR 对象数组中移除给定对象。 - -# 处理输入更改 - -当输入字段中的任何 VR 对象详细信息发生更改时,`handleChange`方法将更新`VRObjectForm`组件的状态,并使用`GameForm`作为道具传递的`handleUpdate`方法将`GameForm`状态中的 VR 对象更新为对象详细信息的更改值。 - -`mern-vrgame/client/game/VRObjectForm.js`: - -```jsx -handleChange = name => event => { - this.setState({[name]: event.target.value}) - this.props.handleUpdate(this.props.index, - this.props.type, - name, - event.target.value) -} -``` - -有了这个实现,创建和编辑游戏表单就就位了,并为不同大小的数组提供了 VR 对象输入表单。任何注册用户都可以使用这些表单在 MERN VR 游戏应用上添加和编辑游戏。 - -# 游戏列表视图 - -MERN VR 游戏的访问者将从主页上的列表和个人用户配置文件中访问应用上的游戏。主页将列出应用上的所有游戏,特定制造商的游戏将列在其用户配置文件页面上。列表视图将迭代使用`list`API 获取的游戏数据,并在`GameDetail`组件中呈现每个游戏的详细信息。 - -# 所有游戏 - -当组件挂载时,`Home`组件将使用列表 API 获取游戏集合中所有游戏的列表。 - -`mern-vrgame/client/core/Home.js`: - -```jsx -componentDidMount = () => { - list().then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({games: data}) - } - }) -} -``` - -从服务器检索到的游戏列表将被设置为状态,并迭代以呈现列表中每个游戏的`GameDetail`组件。 - -`mern-vrgame/client/core/Home.js`: - -```jsx -{this.state.games.map((game, i) => { - return -})} -``` - -`GameDetail`组件将被传递游戏细节和`updateGames`方法。 - -`mern-vrgame/client/core/Home.js`: - -```jsx -updateGames = (game) => { - const updatedGames = this.state.games - const index = updatedGames.indexOf(game) - updatedGames.splice(index, 1) - this.setState({games: updatedGames}) -} -``` - -当用户从使用游戏制作者的`edit`和`delete`选项呈现的`GameDetail`组件中删除游戏时,`updateGames`方法将更新`Home`组件中的列表: - -![](img/f7fa0681-c359-45f1-98a2-7b5fb4dc9256.png) - -# 制造商的游戏 - -用户`Profile`组件将通过 maker API 获取给定用户制作的游戏列表。在检索到用户详细信息后,我们将更新`Profile`组件中的`init`方法来调用`listByMaker`获取方法。 - -`mern-vrgame/client/user/Profile.js`: - -```jsx - init = (userId) => { - const jwt = auth.isAuthenticated() - read({ - userId: userId - }, {t: jwt.token}).then((data) => { - if (data.error) { - this.setState({redirectToSignin: true}) - } else { - this.setState({user: data}) - listByMaker({userId: data._id}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.setState({games: data}) - } - }) - } - }) - } -``` - -类似于在`Home`组件中呈现游戏列表的方式,我们将在`Profile`组件中将从服务器检索到的游戏列表设置为状态,并在视图中对其进行迭代以呈现`GameDetail`组件,该组件将被传递单个游戏细节和`updateGames`方法。 - -`mern-vrgame/client/user/Profile.js`: - -```jsx -{this.state.games.map((game, i) => { - return -})} -``` - -这将为特定用户制作的每个游戏呈现一个`GameDetail`组件: - -![](img/e63a7011-94eb-420a-bd20-13ad86f97746.png) - -# 游戏细节组件 - -`GameDetail`组件将游戏对象作为道具,渲染游戏细节,以及链接到 VR 游戏视图的“玩游戏”按钮。如果当前用户是游戏的制作者,它还会显示`edit`和`delete`按钮: - -![](img/1bc66f95-a3b4-48e3-af8b-7f82d69f4381.png) - -# 游戏详情 - -游戏的详细信息,如名称、世界图像、线索文本和制造商名称,将呈现给用户游戏的概览。 - -`mern-vrgame/client/game/GameDetail.js`: - -```jsx - - {this.props.game.name} - - - - by - {this.props.game.maker.name} - - - - {this.props.game.clue} - - -``` - -# 玩游戏按钮 - -`GameDetail`组件中的`Play Game`按钮将只是一个`Link`组件,指向打开`index.html`生成的 React 360 的路由(服务器上该路由的实现在*玩 VR 游戏*部分讨论)。 - -`mern-vrgame/client/game/GameDetail.js`: - -```jsx - - - -``` - -到游戏视图的路由将游戏 ID 作为一个`query`参数。我们在`Link`上设置了`target='_self'`,所以 React 路由跳过转换到下一个状态,并让浏览器处理此链接。这将允许浏览器直接在此路由上发出请求,并呈现服务器响应此请求发送的`index.html`文件。 - -# 编辑和删除按钮 - -只有当前登录的用户也是正在呈现的游戏的制作者时,`GameDetail`组件才会显示`edit`和`delete`选项。 - -`mern-vrgame/client/game/GameDetail.js`: - -```jsx -{auth.isAuthenticated().user - && auth.isAuthenticated().user._id == this.props.game.maker._id && - (
- - - - -
)} -``` - -如果登录用户的用户 ID 与游戏中的 maker ID 匹配,则视图中会显示链接到编辑表单视图的`edit`按钮和`DeleteGame`组件。 - -# 删除游戏 - -登录用户可以通过点击`GameDetail`组件中制造商可见的`delete`按钮删除他们制作的特定游戏。`GameDetail`组件使用`DeleteGame`组件添加此`delete`选项。 - -# 删除游戏组件 - -为每个游戏添加到`GameDetail`组件的`DeleteGame`组件将游戏细节和`removeGame`方法作为`GameDetail`的道具,更新`GameDetail`所属的父组件。 - -`mern-vrgame/client/game/GameDetail.js`: - -```jsx - -``` - -这个`DeleteGame`组件基本上是一个按钮,点击后会打开一个确认对话框,询问用户是否确定要删除游戏: - -![](img/7d45088a-0e4c-4071-bbb9-a143748b71c8.png) - -该对话框使用 Material UI 中的`Dialog`组件实现。 - -`mern-vrgame/client/game/DeleteGame.js`: - -```jsx - - - {"Delete "+this.props.game.name} - - - Confirm to delete your game {this.props.game.name}. - - - - - - - -``` - -成功删除后,对话框关闭,并通过调用作为道具传入的`removeGame`方法更新包含`GameDetail`组件的父组件。 - -`mern-vrgame/client/game/DeleteGame.js`: - -```jsx -deleteGame = () => { - const jwt = auth.isAuthenticated() - remove({ - gameId: this.props.game._id - }, {t: jwt.token}).then((data) => { - if (data.error) { - console.log(data.error) - } else { - this.props.removeGame(this.props.game) - this.setState({open: false}) - } - }) - } -``` - -此`deleteGame`处理程序方法中调用的`removeGame`方法更新父级的状态,可以是`Home`组件或用户`Profile`组件,因此删除的游戏不再显示在视图中。 - -# 玩虚拟现实游戏 - -MERN VR 游戏的用户将能够在应用中打开和玩任何游戏。为了实现这一点,我们将在服务器上设置一条路由,在响应以下路径的 GET 请求时呈现由 React 360 生成的`index.html`: - -```jsx -/game/play?id= -``` - -该路径将游戏 ID 值作为一个`query`参数,该参数在 React 360 代码中用于通过读取 API 获取游戏详细信息。 - -# 渲染虚拟现实游戏视图的 API - -打开 React 360`index.html`页面的 GET 请求将在`game.routes.js`中声明,如下所示。 - -`mern-vrgame/server/routes/game.routes.js`: - -```jsx -router.route('/game/play') - .get(gameCtrl.playGame) -``` - -这将执行`playGame`控制器方法返回`index.html`页面,以响应传入请求。 - -`mern-vrgame/server/controllers/game.controller.js`: - -```jsx -const playGame = (req, res) => { - res.sendFile(process.cwd()+'/server/vr/index.html') -} -``` - -`playGame`控制器方法将`/server/vr/`文件夹中的`index.html`发送给请求客户端。 - -在浏览器中,这将呈现 React 360 游戏代码,该代码将使用 read API 从数据库中获取游戏详细信息,并呈现游戏世界以及用户可以交互的 VR 对象。 - -# 更新 React 360 中的游戏代码 - -在 MERN 应用中设置好游戏后端后,我们可以更新[第 10 章](10.html)、*开发基于 Web 的虚拟现实游戏*中开发的 React 360 项目代码,使其直接从数据库中的游戏集合中渲染游戏。 - -我们将在打开 React 360 应用的链接中使用游戏 ID,从 React 360 代码中通过读取 API 获取游戏细节,然后将数据设置为状态,以便游戏加载从数据库检索到的细节,而不是我们在[第 10 章](10.html)*开发基于 Web 的 VR 游戏时使用的静态样本数据*。 - -代码更新后,我们可以再次将其捆绑,并将编译后的文件放入 MERN 应用中。 - -# 从链接获取游戏 ID - -在 React 360 项目文件夹的`index.js`文件中,更新`componentDidMount`方法,从传入 URL 检索游戏 ID,并对读取的游戏 API 进行获取调用。 - -`/MERNVR/index.js`: - -```jsx -componentDidMount = () => { - let gameId = Location.search.split('?id=')[1] - read({ - gameId: gameId - }).then((data) => { - if (data.error) { - this.setState({error: data.error}); - } else { - this.setState({ - vrObjects: data.answerObjects.concat(data.wrongObjects), - game: data - }); - Environment.setBackgroundImage( - {uri: data.world} - ) - } - }) -} -``` - -`Location.search`允许我们访问加载`index.html`的传入 URL 中的查询字符串。检索到的查询字符串为`split`,用于从 URL 中附加的`id`查询参数中获取游戏 ID 值。我们需要这个游戏 ID 值来通过读取 API 从服务器获取游戏详细信息,并将其设置为游戏状态和`vrObjects`值。 - -# 使用读取 API 获取游戏数据 - -在 React 360 项目文件夹中,我们将添加一个`api-game.js`文件,该文件将包含一个读取`fetch`方法,该方法使用提供的游戏 ID 在服务器上调用读取游戏 API。 - -`/MERNVR/api-game.js`: - -```jsx -const read = (params) => { - return fetch('/api/game/' + params.gameId, { - method: 'GET' - }).then((response) => { - return response.json() - }).catch((err) => console.log(err)) -} -export { - read -} -``` - -此获取方法用于 React 360 entry 组件的`componentDidMount`中,以获取游戏详细信息。 - -This updated React 360 code is available in the branch named 'dynamic-game' on the GitHub repository at: [github.com/shamahoque/MERNVR](https://github.com/shamahoque/MERNVR). - -# 捆绑和集成更新的代码 - -通过更新 React 360 代码以从服务器动态获取和呈现游戏细节,我们可以使用提供的捆绑脚本捆绑这些代码,并将新编译的文件放在 MERN VR 游戏项目目录的`dist`文件夹中。 - -要从命令行绑定 React 360 代码,请转到 React 360`MERNVR`项目文件夹并运行: - -```jsx -npm run bundle -``` - -这将在`build/`文件夹中生成具有更新代码的`client.bundle.js`和`index.bundle.js`捆绑文件。这些文件以及`index.html`和`static_assets`文件夹需要添加到 MERN VR 游戏应用代码中,如[第 10 章](10.html)*开发基于 Web 的 VR 游戏*所述,以集成最新的 VR 游戏代码。 - -完成此集成后,如果我们运行 MERN VR 游戏应用,并单击任何游戏上的 Play Game 链接,它将打开游戏视图,其中包含在 VR 场景中渲染的特定游戏的详细信息,并允许与游戏中指定的 VR 对象交互。 - -# 总结 - -在本章中,我们将 MERN stack 技术的功能与 React 360 集成,以开发一个用于 web 的动态 VR 游戏应用。 - -我们扩展了 MERN skeleton 应用,以构建一个存储 VR 游戏详细信息的工作后端。并允许我们调用 API 来处理这些细节。我们添加了 React 视图,允许用户修改游戏和浏览游戏,并选择在服务器直接渲染的指定路径上启动和玩 VR 游戏。 - -最后,我们更新了 React 360 项目代码,通过从传入 URL 检索查询参数,并使用 fetch 通过游戏 API 检索数据,在 MERN 应用和 VR 游戏视图之间传递数据。 - -React 360 代码与 MERN stack 应用的集成产生了一个功能全面、动态的基于 web 的 VR 游戏应用,展示了如何使用和扩展 MERN stack 技术,以创造独特的用户体验 - -在下一章中,我们将回顾本书中构建的 MERN 应用,不仅讨论遵循的最佳实践,还将讨论改进和进一步开发的范围。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/12.md b/docs/full-stk-react-proj/12.md deleted file mode 100644 index 2d81e06..0000000 --- a/docs/full-stk-react-proj/12.md +++ /dev/null @@ -1,471 +0,0 @@ -# 十二、遵循最佳实践并进一步开发 MERN - -在本章中,我们将详细介绍在构建本书中的四个 MERN 应用时应用的一些最佳实践,以及本书中未应用的其他实践,但在实际应用中应考虑这些实践,以确保随着复杂性的增加而提高可靠性和可伸缩性。最后,我们总结了关于增强的建议,以及扩展所构建应用的步骤。 - -本章涵盖的主题包括以下内容: - -* 应用结构中模块化关注点的分离 -* 考虑 CSS 样式解决方案的选项 -* 使用选定视图的数据进行服务器端渲染 -* 对有状态组件和纯功能组件使用 ES6 类 -* 决定使用 Redux 或 Flux -* 用于存储用户凭据的安全增强功能 -* 编写测试代码 -* 优化束大小 -* 如何向现有应用添加新功能 - -# 模块化关注点分离 - -在构建 MERN 堆栈应用时,我们在每个应用中遵循一个通用文件夹结构,它根据相关性和通用功能对代码进行划分和分组。在代码中创建这些较小且不同的部分背后的想法是确保每个部分解决一个单独的问题,以便可以重用各个部分,以及独立地开发和更新。 - -# 重新访问应用文件夹结构 - -更具体地说,在应用文件夹结构中,我们将客户端代码和服务器端代码分开,并在这两个部分中进一步细分。这使我们可以自由地独立设计和构建应用的前端和后端: - -```jsx -| mern_application/ - | -- client/ - | -- server/ -``` - -在`client`和`server`部分中,我们将代码进一步划分为子文件夹,这些子文件夹映射到独特的功能,例如模型、控制器和服务器中的路由到特定功能,例如将与客户端用户相关的所有组件分组。 - -# 服务器端代码 - -在服务器端,我们根据功能划分代码,将定义业务模型的代码与实现路由逻辑的代码以及在这些路由上响应客户端请求的代码分开: - -```jsx - | -- server/ - | --- controllers/ - | --- models/ - | --- routes/ -``` - -在此结构中,每个文件夹包含具有特定用途的代码: - -* **模型**:此文件夹用于在单独的文件中包含所有 Mongoose 模式模型定义,每个文件代表一个模型。 -* **路由**:此文件夹包含允许客户端与服务器交互的所有路由-放置在单独的文件中,每个文件可能与 models 文件夹中的模型关联。 -* **控制器**:包含所有控制器功能,这些功能定义了在定义的路由上响应传入请求的逻辑,分为与相关模型和路由文件相对应的单独文件。 - -正如整本书所展示的,服务器端代码的这些特定关注点分离允许我们通过添加所需的模型、路由和控制器文件来扩展为框架应用开发的服务器。 - -# 客户端代码 - -MERN 应用的客户端代码主要由 React 组件组成。为了以合理和可理解的方式组织组件代码和相关辅助代码,我们将代码分为与特征实体或唯一功能相关的文件夹: - -```jsx - | -- client/ - | --- auth/ - | --- core/ - | --- post/ - | --- user/ - | --- componentFolderN/ -``` - -在前面的结构中,我们将所有与 auth 相关的组件和助手代码放在`auth`文件夹中,将常见和基本组件,如`Home`和`Menu`组件放在`core`文件夹中,然后我们在各自的文件夹中为所有与 post 相关或与用户相关的组件制作`post`和`user`文件夹。 - -这种基于功能的组件分离和分组允许我们通过在客户机文件夹中添加新的功能相关组件代码文件夹(根据需要),为随后的每个应用扩展框架应用中的前端视图。 - -在本章的最后一节中,我们将进一步展示这种分离应用代码的模块化方法的优势,因为我们将概述可用于向本书中开发的任何现有应用添加新功能的通用工作流。 - -# 添加 CSS 样式 - -在本书中讨论应用的用户界面实现时,我们选择不关注应用的 CSS 样式代码的细节,而主要依赖于默认的材质 UI 样式。但是,考虑到实现任何用户界面都需要考虑样式化解决方案,我们将简要介绍一些可用的选项 - -在前端添加 CSS 样式时,有许多选项,每个选项都有优点和缺点。在本节中,我们将讨论两个最常见的选项,即外部样式表和内联样式,以及一种用 JavaScript 编写 CSS 的新方法,或者更具体地说是 JSS,它用于 Material UI 组件,因此也适用于本书中的应用。 - -# 外部样式表 - -外部样式表允许我们在单独的文件中定义 CSS 规则,这些文件可以注入到必要的视图中。在外部样式表中以这种方式放置 CSS 样式一度被认为是更好的做法,因为它强制了样式和内容的分离,允许重用,并且在为每个组件创建单独的 CSS 文件时也保持模块化。 - -然而,随着 web 开发技术的不断发展,这种方法不再能够满足更好的 CSS 组织和性能的需求。例如,在使用 React 组件开发前端视图时使用外部样式表会限制对基于组件状态更新样式的控制。此外,为 React 应用加载外部 CSS 需要额外的带有`css-loader`和`style-loader`的网页包配置。 - -当应用增长并共享多个样式表时,也无法避免选择器冲突,因为 CSS 只有一个全局名称空间。因此,尽管外部样式表对于简单和琐碎的应用来说已经足够了,但随着应用的增长,使用 CSS 的其他选项变得更加相关。 - -# 内联样式 - -内联 CSS 是一种定义的样式,直接应用于视图中的各个元素。尽管这解决了外部样式表所面临的一些问题,例如消除选择器冲突和允许依赖于状态的样式,但它带走了可重用性并引入了一些自身的问题,例如限制可以应用的 CSS 特性。 - -对于不断增长的应用,仅对基于 React 的前端使用内联 CSS 具有重要的限制,例如性能差,因为在每次渲染时都会重新计算所有内联样式,并且内联样式比开始时的类名慢。 - -在某些情况下,内联 CSS 似乎是一个简单的修复方法,但对于整体使用来说并不是一个好的选择。 - -# JSS - -JSS 允许我们以声明的方式使用 JavaScript 编写 CSS 样式。这也意味着 JavaScript 的所有特性现在都可用于编写 CSS,从而可以编写可重用和可维护的样式代码。 - -JSS 是一个 JS-to-CSS 编译器,它接受 JS 对象,其中键表示类名,值表示相应的 CSS 规则,然后生成 CSS 和作用域类名。 - -通过这种方式,JSS 在将 JSON 表示编译为 CSS 时默认生成唯一的类名,从而消除了外部样式表面临的选择器冲突的机会。此外,与内联样式不同,使用 JSS 定义的 CSS 规则可以跨多个元素共享,并且所有 CSS 特性都可以在定义中使用。 - -Material UI 使用 JSS 为其组件设置样式,因此我们使用 JSS 将 Material UI 主题和自定义 CSS 应用于为所有应用中的前端视图开发的组件。 - -# 使用数据的选择性服务器端渲染 - -当我们在[第 4 章](04.html)中开发基础骨架应用的前端时,*添加了一个 React 前端来完成 MERN*时,我们集成了基本的服务器端渲染,当请求到达服务器时,能够直接从浏览器地址栏加载客户端路由。在这个 SSR 实现中,在呈现反作用组件服务器端时,我们没有考虑从数据库中加载显示数据的组件的数据。只有当客户端 JavaScript 在服务器端呈现标记的初始加载之后接管时,数据才会加载到这些组件中。 - -我们确实更新了这个实现,在 MERN Mediastream 应用中添加了服务器端呈现,其中包含[第 9 章](09.html)、*定制媒体播放器和改进 SEO*中讨论的各个媒体详细信息页面的数据。在本例中,我们决定通过向 React 前端的服务器端生成的标记中注入数据来呈现具有数据的特定视图。这种仅对特定视图使用数据的选择性服务器端渲染背后的推理可以基于所讨论视图的某些期望行为。 - -# 什么时候 SSR 与数据相关? - -在应用中的所有反应视图中实现具有数据的服务器端渲染可能变得复杂,并且当需要考虑客户端认证或由多个数据源组成的视图时,需要额外的工作。在许多情况下,如果视图不需要使用数据进行服务器端渲染,则可能没有必要处理这些复杂性。为了判断视图是否需要由服务器呈现数据,请针对特定视图回答以下问题以做出决定: - -* 当浏览器中可能没有 JavaScript 时,在视图的初始加载中显示数据是否重要? -* 视图及其数据是否需要对 SEO 友好? - -从可用性的角度来看,在页面的初始加载中加载数据可能是相关的,因此它实际上取决于特定视图的用例。对于 SEO,使用数据的服务器端呈现将使搜索引擎更容易访问视图中的数据内容,因此,如果这对所讨论的视图至关重要,那么使用数据添加服务器端呈现是一个好主意。 - -# 对有状态组件和纯功能组件使用 ES6 类 - -在使用 React 组件构建 UI 时,使用更多无状态功能组件组合视图可以使前端代码易于管理、干净和测试。但有些组件需要状态或生命周期挂钩,而不仅仅是纯粹的表示组件。在本节中,我们将介绍如何构建有状态和无状态的功能组件,何时使用一个或另一个,以及使用频率。 - -# 将组件与 ES6 类进行反应 - -使用 ES6 类定义的 React 组件可以访问生命周期方法,`this`关键字,并且在构建有状态组件时可以使用`setState`管理状态。有状态组件允许我们构建交互式组件,这些组件可以管理状态中不断变化的数据,并传播需要在 UI 中应用的任何业务逻辑。一般来说,对于复杂的 UI,有状态组件应该是更高级别的容器组件,用于管理它们所组成的较小的无状态功能组件的状态。 - -# 将组件作为纯函数进行反应 - -React 组件可以使用 ES6 类语法定义为无状态功能组件,也可以定义为纯函数。其主要思想是无状态组件不修改状态并接收道具。 - -以下代码使用 ES6 类语法定义无状态组件: - -```jsx -class Greeting extends React.Component { - render() { - return

Hello, {this.props.name}

- } -} -``` - -也可以使用 JavaScript 纯函数定义,如下所示: - -```jsx -function Greeting(props) { - return

Hello, {props.name}

-} -``` - -当给定相同的输入时,纯函数总是给出相同的输出,而没有任何副作用。将 React 组件建模为纯函数将强制创建更小、定义更明确、自包含的组件,这些组件强调 UI 而不是业务逻辑,因为这些组件中没有状态操纵。这些类型的组件是可组合的、可重用的,并且易于调试和测试。 - -# 使用有状态组件和无状态功能组件设计 UI - -在考虑 UI 的组件组合时,将根组件或父组件设计为有状态组件,其中包含子组件或仅接收道具且无法操作状态的可组合组件。所有使用`setState`的状态更改操作和生命周期问题将由根组件或父组件处理。 - -在为本书开发的应用中,混合了有状态的高级组件和较小的无状态组件。例如,在 MERN 社交应用中,`Profile`组件修改无状态子组件的状态,例如`FollowProfileButton`和`FollowGrid`组件。可以将本书中开发的一些较大组件重构为更小、更独立的组件,在扩展应用以包含更多功能之前,应该考虑这一点。 - -可以应用于新组件设计或重构现有组件的主要收获是,随着 React 应用的增长和变得更加复杂,最好将更多无状态功能组件添加到负责管理其内部组件状态的高级有状态组件中。 - -# 使用 Redux 或 Flux - -当 React 应用开始增长并变得更加复杂时,管理组件之间的通信可能会出现问题。使用 React 时,通信的方式是将值和回调函数作为道具传递给子组件。但是,如果回调必须通过很多中间组件,那么这可能会很乏味。随着 React 应用的发展,为了解决这些与状态通信和管理相关的问题,人们转向使用 React 与库和体系结构模式,如 Redux 和 Flux。 - -在本书的范围之外,深入研究与 ReDux 库或 FLUX 架构集成的细节,但读者可以考虑这些选项以供其不断增长的 Mern 应用,同时请记住以下几点: - -* Redux 和 Flux 利用了从中心位置强制 React 应用中改变状态的模式。避免在可管理规模的 React 应用中使用 Redux 或 Flux 的一个技巧是将组件树中的所有状态更改向上移动到父组件。 -* 较小的应用在没有 Flux 或 Redux 的情况下也能正常工作。 - -You can learn more about using React with Redux at [https://redux.js.org/](https://redux.js.org/), and Flux at [facebook.github.io/flux/](http://facebook.github.io/flux/). - -# 加强安全 - -在为本书开发的 MERN 应用中,我们使用 JSON Web 令牌作为身份验证机制,并在用户集合中存储哈希密码,从而简化了与身份验证相关的安全实现。在本节中,我们将回顾这些选择并指出可能的增强。 - -# JSON web 令牌–客户端或服务器端存储 - -通过 JWT 身份验证机制,客户端负责维护用户状态。用户登录后,服务器发送的令牌由客户端代码在浏览器存储器上存储维护,如`sessionStorage`。因此,当用户注销或需要注销时,客户端代码也可以通过删除令牌来使令牌无效。这种机制适用于大多数需要最少身份验证以保护对资源的访问的应用。但是,对于可能需要跟踪用户登录、注销以及让服务器知道特定令牌不再对登录有效的情况,仅客户端处理令牌是不够的。 - -对于这些情况,讨论的在客户端处理 JWT 令牌的实现也可以扩展到服务器端的存储。在跟踪失效令牌的特定情况下,服务器可以维护 MongoDB 集合以存储这些失效令牌作为参考,这与在服务器端存储会话数据的方式有些类似。 - -需要注意的是,在大多数情况下,在客户端和服务器端存储和维护与身份验证相关的信息可能过于繁琐。因此,这完全取决于具体用例和要考虑的相关权衡。 - -# 保护密码存储 - -在用户集合中存储用于身份验证的用户凭据时,我们确保用户提供的原始密码字符串不会直接存储在数据库中。相反,我们使用节点中的`crypto`模块生成了密码散列和 salt 值。 - -在我们的应用的`user.model.js`中,我们定义了以下函数来生成散列的`password`和`salt`值: - -```jsx -encryptPassword: function(password) { - if (!password) return '' - try { - return crypto - .createHmac('sha1', this.salt) - .update(password) - .digest('hex') - } catch (err) { - return '' - } - }, - makeSalt: function() { - return Math.round((new Date().valueOf() * Math.random())) + '' - } -``` - -在这个实现中,每当用户输入密码登录时,salt 就会生成一个哈希。如果生成的哈希与存储的哈希匹配,则密码正确,否则密码错误。因此,为了检查密码是否正确,salt 是必需的,因此它与用户详细信息一起存储在数据库中以及散列。 - -这是保护存储用于用户身份验证的密码的标准做法,但如果特定应用的安全需求需要,还可以探索其他高级方法。可以考虑的一些选项包括多迭代散列方法、其他安全散列算法、限制每个用户帐户的登录尝试,以及带有附加步骤(如回答安全问题或输入安全代码)的多级身份验证。 - -# 编写测试代码 - -尽管讨论和编写测试代码超出了本书的范围,但它对于开发可靠的软件至关重要。在本节中,首先我们将介绍可用于测试 MERN 应用不同部分的测试工具。然后,为了帮助开始编写本书中开发的 MERN 应用的测试代码,我们还将讨论一个真实的例子,从[第 5 章](05.html)*开始,从一个简单的社交媒体应用*开始,向 MERN 社交应用添加客户端测试。 - -# 开玩笑地测试 - -Jest 是一个针对 JavaScript 的综合测试框架。虽然它更常见于测试 React 组件,但它可以用于任何 JavaScript 库或框架的通用测试。在 Jest 中的众多 JavaScript 测试解决方案中,它提供了对模拟和快照测试的支持,并附带了断言库,Jest 中的测试是以**行为驱动开发**(**BDD**风格编写的。除了测试 React 组件外,Jest 还可以根据需要为基于 Node Express Mongoose 的后端编写测试代码。因此,为 MERN 应用添加测试代码是一个可靠的测试选项。 - -To learn more about Jest, read the docs at [https://facebook.github.io/jest/docs/en/getting-started.html](https://facebook.github.io/jest/docs/en/getting-started.html). - -# 向 MERN 社交应用添加测试 - -使用 Jest,我们将向 MERN 社交应用添加客户端测试,并演示如何开始向 MERN 应用添加测试。 - -在编写测试代码之前,首先我们将安装必要的软件包,定义测试运行脚本,并为测试代码创建一个`tests`文件夹,以进行测试。 - -# 安装软件包 - -编写测试代码和运行测试需要以下 npm 包: - -* **jest**:包括 jest 测试框架 -* **babel jest**:为 jest 编译 JS 代码 -* **react test renderer**:在不使用浏览器的情况下,对 react DOM 呈现的 DOM 树进行快照 - -要将这些软件包安装为`devDependencies`,请从命令行运行以下`npm install`命令: - -```jsx -npm install --save-dev jest babel-jest react-test-renderer -``` - -# 定义运行测试的脚本 - -为了运行测试代码,我们将更新`package.json`中定义的运行脚本,添加一个运行测试的脚本`jest`: - -```jsx - "scripts": { - "test": "jest" - } -``` - -在命令行中,如果我们运行`npm run test`,它将提示 Jest 在应用文件夹中查找测试代码并运行测试。 - -# 添加测试文件夹 - -要在 MERN 社交应用中添加客户端测试,我们将在客户端文件夹中创建一个名为`tests`的文件夹,其中将包含与测试 React 组件相关的测试文件。当运行 test 命令时,Jest 将在这些文件中查找测试代码。 - -本例的测试用例是对`Post`组件的测试,而`Post`组件的测试将添加到`tests`文件夹中名为`post.test.js`的文件中。 - -# 测试用例 - -我们将编写一个测试来检查帖子上的`delete`按钮是否只有在登录用户也是帖子的创建者时才可见。这意味着,如果经过身份验证的用户的`user._id`值与正在呈现的 Post 数据的`postedby`值相同,则删除按钮将仅是 Post 视图的一部分。 - -# 添加测试 - -为了实现此测试用例,我们将添加代码,其中包含以下内容: - -* 为 post 和`auth`对象定义虚拟数据 -* 模仿`auth-helper.js` -* 定义测试,并在测试定义内 - * 声明`post`和`auth`变量 - * 将模拟的`isAuthenticated`方法的返回值设置为虚拟`auth`对象 - * 使用`renderer.create`创建`Post`组件,将所需的虚拟道具传递并包裹在`MemoryRouter`中,以提供与`react-router`相关的道具 - * 生成并匹配快照 - -`post.test.js`中包含此特定测试所述步骤的代码如下: - -```jsx -import auth from './../auth/auth-helper.js' -import Post from './../post/Post.js' -import React from 'react' -import renderer from 'react-test-renderer' -import { MemoryRouter } from 'react-router-dom' - -jest.mock('./../auth/auth-helper.js') - -const dummyPostObject = {"_id":"5a3cb2399bcc621874d7e42f", - "postedBy":{"_id":"5a3cb1779bcc621874d7e428", - "name":"Joe"}, "text":"hey!", - "created":"2017-12-22T07:20:25.611Z", - "comments":[], "likes":[]} -const dummyAuthObject = {user: {"_id":"5a3cb1779bcc621874d7e428", - "name":"Joe", - "email":"abc@def.com"}} - -test('delete option visible only to authorized user', () => { - const post = dummyPostObject - const auth = dummyAuthObject - - auth.isAuthenticated.mockReturnValue(auth) - - const component = renderer.create( - - - - ) - - let tree = component.toJSON() - expect(tree).toMatchSnapshot() -}) -``` - -# 生成正确 Post 视图的快照 - -第一次运行此测试时,我们将为它提供生成 Post 视图的正确快照所需的值。当 auth 对象的`user._id`等于 post 对象的`postedBy`值时,此测试用例的正确快照将包含 delete 按钮。第一次运行测试时生成的此快照将用于将来执行测试时的比较。 - -Snapshot testing in Jest basically records snapshots of rendered component structures to compare them to future renderings. When the recorded snapshot and the current rendering don’t match, the test fails, indicating that something has changed. - -# 运行并检查测试 - -在我们刚刚添加到`post.test.js`的代码中,虚拟`auth`对象和`post`对象指的是同一个用户,因此在命令行中运行此测试将提示 Jest 生成包含删除选项的快照,并通过测试。 - -要运行测试,请从命令行转到项目文件夹: - -```jsx -npm run test -``` - -测试输出将显示测试通过: - -![](img/dd786cee-c6d3-4d46-8eab-b7dfc375cff2.png) - -当该测试首次成功运行时,生成的记录快照将自动添加到`tests`文件夹中的`_snapshots_`文件夹中。此快照表示视图中呈现“删除”按钮的状态,因为经过身份验证的用户也是帖子的创建者 - -现在,我们可以检查当组件由非帖子创建者的经过身份验证的用户呈现时,测试是否实际失败。要执行此检查,我们将通过更改`user._id`以不匹配`postedBy`值来更新虚拟数据对象,然后再次运行测试。这将给我们一个失败的测试,因为当前渲染将不再具有记录的快照中存在的删除按钮。 - -如下图中的测试日志所示,测试失败,并表明呈现的树与记录的快照不匹配,因为代表 delete 按钮的元素在接收到的值中丢失: - -![](img/5c11456c-887a-450f-ba95-0a8eb79dd656.png) - -通过这个屏幕截图,我们有一个客户端测试,用于检查登录用户是否可以查看其帖子上的`delete`按钮。使用此设置,可以利用 Jest 的功能为 MERN 应用添加更多测试。 - -编写测试代码将使您开发的应用可靠,并有助于确保代码质量。提高和维护代码质量的另一个良好实践是在项目中使用 linting 工具。Linting 工具对代码执行静态分析,以发现违反指定规则和准则的问题模式或行为。JavaScript 项目中的 Linting 代码可以提高代码的整体可读性,还可以帮助在代码执行之前发现语法错误。对于基于 MERN 的项目中的 linting,您可以探索 ESLint,它是一个 JavaScript linting 实用程序,允许开发人员创建自己的 linting 规则。 - -# 优化束大小 - -随着 MERN 应用的开发和发展,使用 Webpack 生成的捆绑包的大小也会增长,特别是在使用大型第三方库的情况下。较大的包大小将影响性能并增加应用的初始加载时间。我们可以对代码进行更改,以确保最终不会出现大型捆绑包,还可以利用 Webpack4 中打包的功能来帮助优化捆绑包。在本节中,我们将重点介绍一些关键概念,这些概念可以让我们控制生成更小的捆绑包和减少加载时间。 - -在进入代码以更新包大小优化之前,您还可以熟悉默认的优化选项,这些选项现在是 Webpack4 的一部分。在 MERN 应用中,我们使用`mode`配置来利用开发和生产模式的默认设置。要获得可用选项的概述,请访问[查看本文 https://medium.com/webpack/webpack-4-mode-and-optimization-5423a6bc597a](https://medium.com/webpack/webpack-4-mode-and-optimization-5423a6bc597a) 。 - -# 代码拆分 - -我们可以使用 Webpack 支持的代码拆分功能,根据用户当前的需要延迟加载部分应用代码,而不是一次在一个包中加载所有代码。在我们修改应用代码以引入代码拆分后,Webpack 可以创建多个包,而不是一个大包。这些捆绑包可以在运行时动态加载,从而提高应用的性能。 - -要了解有关 Webpack 中的代码拆分支持以及如何对设置和配置进行必要更改的更多信息,请查看文档中的指南,网址为[https://webpack.js.org/guides/code-splitting/](https://webpack.js.org/guides/code-splitting/) 。 - -有几种方法可以为应用代码引入代码拆分,但为此您将遇到的最重要的语法是动态的`import()`。在下一节中,我们将了解如何在 MERN 应用中使用`import()` - -# 动态导入() - -动态`import()`是常规导入的一个新的类似函数的版本,它支持动态加载 JS 模块。使用`import(moduleSpecifier)`将返回请求模块的模块名称空间对象的承诺。当使用常规静态导入时,我们在代码顶部导入一个模块,然后在代码中使用它: - -```jsx -import { convert } from './metric' -... -console.log(convert('km', 'miles', 202)) -``` - -如果我们在开始时使用动态`import()`而不是添加静态导入,代码将如下所示: - -```jsx -import('./metric').then({ convert } => { - console.log( convert('km', 'miles', 202) ) -}) -``` - -这允许在代码需要时导入和加载模块。捆绑应用代码时,Webpack 会将对`import()`的调用视为拆分点,并通过将请求的模块及其子模块放置在主捆绑包的单独块中,自动开始代码拆分。 - -为了通过在给定组件上应用代码拆分来优化前端 React 代码的捆绑,我们需要将动态`import()`与 React Loadable—一个用于加载承诺组件的高阶组件配对。例如,我们将查看[第 7 章](07.html)、*中开发的购物车扩展订单和支付市场*。在构建 cart 的界面时,我们通过导入`Checkout`组件并将其添加到视图中,组成了`Cart`组件,如下所示: - -```jsx -import Checkout from './Checkout' -class Cart extends Component { - ... - render(){ - ... - - } - ... -} -``` - -为了在这里引入代码拆分,动态导入`Checkout`组件,我们可以用`Loadable`签出代替开始时的静态导入,如下代码所示: - -```jsx -import Loadable from 'react-loadable' -const Checkout = Loadable({ - loader: () => import('./Checkout'), - loading: () =>
Loading...
, -}) -``` - -进行此更改并再次使用 Webpack 构建代码将生成一个大小减小的`bundle.js`文件,并生成另一个表示拆分代码的较小捆绑文件,该文件现在仅在呈现`Cart`组件时加载。 - -使用这种机制,我们可以根据需要在应用代码中应用代码拆分。需要记住的是,有效的代码拆分将取决于正确使用它,并将其应用于代码中的正确位置,这将有助于优化资源负载优先级 - -Route-based code splitting can be an effective approach for introducing code splitting in React apps that use routes to load components in the view. To learn more about implementing code splitting, specifically with React Router, check out the article at [https://tylermcginnis.com/react-router-code-splitting/](https://tylermcginnis.com/react-router-code-splitting/). - -# 扩展应用 - -在本书的所有章节中,当我们开发每个应用时,我们通过扩展现有代码添加了一些功能,这些功能是通过一个通用的、可重复的步骤来实现的。在最后一节中,我们将回顾这些步骤,并为向当前版本的应用添加更多功能制定指导方针。 - -# 扩展服务器代码 - -对于需要数据持久性和 API 以允许视图操作数据的特定功能,我们可以从扩展服务器代码和添加必要的模型、路由和控制器功能开始。 - -# 添加模型 - -对于特性的数据持久性方面,设计数据模型时考虑需要存储的字段和值。然后,在`server/models`文件夹中的单独文件中定义并导出此数据模型的 Mongoose 模式。 - -# 实现 API - -接下来,设计与所需功能相关的 API,以便根据模型操作和访问将存储在数据库中的数据。 - -# 添加控制器 - -确定 API 后,在`server/controllers`文件夹中的单独文件中添加相应的控制器函数,以响应对这些 API 的请求。此文件中的控制器函数应访问和操作为此功能定义的模型的数据。 - -# 添加路由 - -要完成服务器端 API 的实现,需要在 Express app 上声明并装载相应的路由。在`server/routes`文件夹中的单独文件中,首先声明并导出这些 API 的路由,分配请求特定路由时应执行的相关控制器功能。然后,将这些新路由加载到`server/express.js`文件中的 Express 应用上,就像应用中的其他现有路由一样。 - -这将生成新的后端 API 的工作版本,可以从 REST API 客户端应用运行和检查,然后再继续为正在开发的功能构建和集成前端视图。 - -# 扩展客户端代码 - -在客户端,首先设计功能所需的视图,并确定这些视图将如何结合用户与功能相关数据的交互。然后添加 fetchapi 代码以与新的后端 API 集成,定义表示这些新视图的新组件,并更新现有代码以将这些新组件包含在应用的前端。 - -# 添加 API 获取方法 - -在“客户端”文件夹中,创建一个新文件夹,以容纳与正在开发的功能模块相关的组件和辅助代码。然后,要集成新的后端 API,请在此新组件文件夹中的单独文件中添加并导出相应的获取方法。 - -# 添加组件 - -在新文件夹中的单独文件中创建和导出表示所需特征视图的新构件。使用现有的 auth 助手方法将 auth 集成到这些新组件中。 - -# 加载新组件 - -为了将这些新组件合并到前端,需要将这些组件添加到现有组件中,或者按照其自己的客户端路线进行渲染。 - -# 更新前端路线 - -如果需要在各个路由上呈现这些新组件,请更新`MainRouter.js`代码以添加在给定 URL 路径上加载这些组件的新路由。 - -# 与现有组件集成 - -如果新零部件将成为现有视图的一部分,请将零部件导入现有零部件,以便根据需要将其添加到视图中。新组件也可以与现有组件集成,例如在`Menu`组件中,通过链接到使用单个路由添加的新组件。 - -组件集成并与后端连接后,新功能的实现就完成了。可以重复这些步骤,为应用添加新功能。 - -# 总结 - -在最后一章中,我们在本书中回顾并详细阐述了构建 MERN 应用时使用的一些最佳实践,强调了改进的领域,给出了解决应用增长时可能出现的问题的建议,并最终制定了继续在现有应用中开发更多功能的指导原则。 - -我们看到,模块化应用的代码结构有助于轻松扩展应用,选择使用 JS 而不是内联 CSS 和外部样式表可以保持样式代码的包含性和易用性,并且只根据需要实现特定视图的服务器端呈现可以避免代码中不必要的复杂性。 - -我们讨论了创建由更小、定义更明确的无状态功能组件组成的更少有状态组件的好处,以及在重构现有组件或设计新组件以扩展应用时如何应用这些好处。对于可能遇到跨数百个组件管理和通信状态问题的不断增长的应用,我们指出可以考虑使用 Redux 或 Flux 等选项来解决这些问题。 - -对于可能对更严格的安全实施有更高要求的应用,我们回顾了使用 JWT 和密码加密的用户身份验证的现有实现,并讨论了提高安全性的可能扩展。 - -我们使用 Jest 演示了如何将测试代码添加到 MERN 应用中,并讨论了良好实践(如编写测试代码和使用 linting 工具)如何在确保应用可靠性的同时提高代码质量。 - -我们还研究了捆绑包优化特性,如代码拆分,这些特性可以通过减少初始捆绑包大小和根据需要延迟加载部分应用来帮助提高性能。 - -最后,我们回顾并制定了本书中使用的 repeatabe 步骤,可以作为通过添加更多功能扩展 MERN 应用的指南。 \ No newline at end of file diff --git a/docs/full-stk-react-proj/README.md b/docs/full-stk-react-proj/README.md deleted file mode 100644 index 217a12b..0000000 --- a/docs/full-stk-react-proj/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# React 全栈项目 - -> 原文:[Full-Stack React Projects](https://libgen.rs/book/index.php?md5=05F04F9004AE49378ED0525C32CB85EB) -> -> 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) -> -> 阶段:机翻(1) -> -> 自豪地采用[谷歌翻译](https://translate.google.cn/) -> -> 只有经历过地狱般的磨练,才能拥有创造天堂的力量。——泰戈尔 - -* [在线阅读](https://react.apachecn.org) -* [在线阅读(Gitee)](https://apachecn.gitee.io/doc-template/) -* [ApacheCN 学习资源](http://docs.apachecn.org/) - -## 贡献指南 - -本项目需要校对,欢迎大家提交 Pull Request。 - -> 请您勇敢地去翻译和改进翻译。虽然我们追求卓越,但我们并不要求您做到十全十美,因此请不要担心因为翻译上犯错——在大部分情况下,我们的服务器已经记录所有的翻译,因此您不必担心会因为您的失误遭到无法挽回的破坏。(改编自维基百科) - -## 联系方式 - -### 负责人 - -* [飞龙](https://github.com/wizardforcel): 562826179 - -### 其他 - -* 在我们的 [apachecn/apachecn-react-zh](https://github.com/apachecn/apachecn-react-zh) github 上提 issue. -* 发邮件到 Email: `apachecn@163.com`. -* 在我们的 [组织学习交流群](http://www.apachecn.org/organization/348.html) 中联系群主/管理员即可. - -## 赞助我们 - -![](http://data.apachecn.org/img/about/donate.jpg) diff --git a/docs/full-stk-react-proj/SUMMARY.md b/docs/full-stk-react-proj/SUMMARY.md deleted file mode 100644 index 2c03b25..0000000 --- a/docs/full-stk-react-proj/SUMMARY.md +++ /dev/null @@ -1,14 +0,0 @@ -+ [React 全栈项目](README.md) -+ [零、前言](00.md) -+ [一、使用 MERN 释放 React 应用](01.md) -+ [二、准备开发环境](02.md) -+ [三、使用 MongoDB、Express 和 Node 构建后端](03.md) -+ [四、添加 React 前端来完成 MERN](04.md) -+ [五、从一个简单的社交媒体应用开始](05.md) -+ [六、通过在线市场练习新的 MERN 技能](06.md) -+ [七、为市场扩展订单和支付](07.md) -+ [八、构建媒体流应用](08.md) -+ [九、定制媒体播放器并改进 SEO](09.md) -+ [十、基于 Web 的虚拟现实游戏开发](10.md) -+ [十一、使用 MERN 使虚拟现实游戏动态化](11.md) -+ [十二、遵循最佳实践并进一步开发 MERN](12.md) diff --git a/docs/get-start-rvr/00.md b/docs/get-start-rvr/00.md deleted file mode 100644 index d9a8f84..0000000 --- a/docs/get-start-rvr/00.md +++ /dev/null @@ -1,116 +0,0 @@ -# 零、前言 - -从计算机的角度来看,虚拟现实从 20 世纪 60 年代就已经存在了。它在 90 年代末再次大规模启动,然后几乎崩溃了一段时间,尽管它从未真正消失。它现在回来了,而且,这一次,它在这里停留。 - -使这一变化的是手机——手机中使用的大型高分辨率显示技术帮助创造了 HMD(头戴式显示器或 VR 护目镜)。电路和计算机也比过去快得多;1998 年曾经花费 25 万美元的计算机图形现在花费不到 2000 美元,甚至更快。 - -然而,构建虚拟现实世界一直很困难。你必须是 C++程序员,了解高速编程、实时图形、几何和其他复杂主题的大量知识。在过去几年中,随着游戏开发引擎的简化,这一点已经得到了简化,但只是在一定程度上 - -使用 React VR,它更简单。现在,您可以使用 React 语法(一种类似 HTML 的简单声明性语言)编写一个虚拟现实世界。如果要创建一个长方体,只需声明一个具有正确宽度、高度等的长方体,而无需编写过程代码。语法可能很简单,但这些世界可以是事件驱动的、动画化的、响应用户输入以及从 web 获取信息的世界。 - -这将使您能够使用简单的 JavaScript 和类似 HTML 的代码构建复杂的虚拟世界。这使用了一种新的基于浏览器的编程模式,称为 WebVR;PC 和移动设备上的常规浏览器现在可以在 VR 中查看世界。 - -你也可以这样做,这本书将告诉你如何做 - -# 这本书你需要什么 - -你需要一台几乎任何类型的 Windows PC;为了最大限度地享受,您需要 VR 装备。HTC Vive、Oculus Rift、三星 Gear VR、谷歌白日梦或其他 VR 护目镜(包括谷歌硬纸板)和手机。 - -即使您没有复杂的**头戴式显示器**(**HMD**)或**VR 耳机**,您也可以开发这些 WebVR 世界;你可以在普通电脑屏幕上以平板模式查看它们。你可以用不到 20 美元甚至在许多地方免费购买一个简单的 VR 手机支架/耳机(谷歌硬纸板或类似产品),所以不要让硬件成为学习下一件大事的障碍。 - -# 这本书是给谁的 - -这本书是为任何人谁想要学习虚拟现实的网络和创造迷人的三维网站与网络虚拟现实通过反应虚拟现实。如果您已经了解了一些 JavaScript,那么您将最快地了解到这一点,如果您已经了解 React 或 React Native,则会更快地了解这一点。即使你不这样做,这本书也会一步一步地告诉你该怎么做。如果您已经知道如何进行多边形建模,这将有所帮助,但本书还将向人们展示如何使用免费的开源 Blender 进行一些基本建模(如果需要),以及在哪里可以获得免费下载。你不需要一个虚拟现实设备就可以欣赏这本书,你可以用普通的电脑制作样本,甚至可以在互联网上发布。 - -# 习俗 - -在本书中,您将发现许多文本样式可以区分不同类型的信息。下面是这些风格的一些例子,并解释了它们的含义。 - -文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄如下所示:“组件是真实的东西,而不仅仅是标签或占位符,因为它们通过`render()`函数以某种方式构建了在世界上展现自己的方式。” - -代码块设置如下: - -```jsx - -``` - -当我们希望提请您注意代码块的特定部分时,相关行或项目以粗体显示: - -```jsx -f: -mkdir f:\reactVR -cd \reactVR -``` - -任何命令行输入或输出的编写方式如下: - -```jsx -npm install mersenne-twister --save -``` - -**新术语**和**重要词语**以粗体显示。您在屏幕上看到的文字(例如,在菜单或对话框中)在文本中显示如下:“指定多边形后,单击视图->前部,然后单击网格->UV 展开->圆柱体投影。” - -Warnings or important notes appear in a box like this. Tips and tricks appear like this. - -# 读者反馈 - -我们欢迎读者的反馈。让我们知道你对这本书的看法你喜欢还是不喜欢。读者反馈对我们来说很重要,因为它可以帮助我们开发出您将真正从中获得最大收益的标题。 - -要向我们发送总体反馈,只需发送电子邮件`feedback@packtpub.com`,并在邮件主题中提及该书的标题。 - -如果您对某个主题有专业知识,并且您有兴趣撰写或贡献一本书,请参阅我们的作者指南[www.packtpub.com/authors](http://www.packtpub.com/authors)。 - -# 客户支持 - -既然您是一本 Packt 图书的骄傲拥有者,我们有很多东西可以帮助您从购买中获得最大收益。 - -# 下载示例代码 - -您可以从您的帐户[下载本书的示例代码文件 http://www.packtpub.com](http://www.packtpub.com) 。如果您在其他地方购买了本书,您可以访问[http://www.packtpub.com/support](http://www.packtpub.com/support) 并注册,将文件直接通过电子邮件发送给您。 - -您可以通过以下步骤下载代码文件: - -1. 使用您的电子邮件地址和密码登录或注册我们的网站。 -2. 将鼠标指针悬停在顶部的“支持”选项卡上。 -3. 点击代码下载和勘误表。 -4. 在搜索框中输入图书的名称。 -5. 选择要下载代码文件的书籍。 -6. 从您购买本书的下拉菜单中选择。 -7. 点击代码下载。 - -您也可以通过点击 Packt Publishing 网站上书籍网页上的“代码文件”按钮下载代码文件。可以通过在搜索框中输入图书名称来访问此页面。请注意,您需要登录到您的 Packt 帐户。 - -下载文件后,请确保使用以下最新版本解压或解压缩文件夹: - -* WinRAR/7-Zip for Windows -* 适用于 Mac 的 Zipeg/iZip/UnRarX -* 适用于 Linux 的 7-Zip/PeaZip - -该书的代码包也托管在 GitHub 上的[https://github.com/PacktPublishing/Getting-Started-with-React-VR](https://github.com/PacktPublishing/Getting-Started-with-React-VR) 。我们的丰富书籍和视频目录中还有其他代码包,请访问**[https://github.com/PacktPublishing/](https://github.com/PacktPublishing/)** 。看看他们! - -# 下载本书的彩色图像 - -我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。彩色图像将帮助您更好地了解输出中的更改。您可以从[下载此文件 https://www.packtpub.com/sites/default/files/downloads/GettingStartedwithReactVR_ColorImages.pdf](https://www.packtpub.com/sites/default/files/downloads/GettingStartedwithReactVR_ColorImages.pdf) 。 - -# 勘误表 - -虽然我们已尽一切努力确保内容的准确性,但错误确实会发生。如果您在我们的一本书中发现错误,可能是文本或代码中的错误,如果您能向我们报告,我们将不胜感激。通过这样做,您可以使其他读者免于沮丧,并帮助我们改进本书的后续版本。如果您发现任何错误,请访问[进行报告 http://www.packtpub.com/submit-errata](http://www.packtpub.com/submit-errata) ,选择您的书籍,点击勘误表提交表单链接,然后输入勘误表的详细信息。一旦您的勘误表得到验证,您的提交将被接受,勘误表将上载到我们的网站或添加到该标题勘误表部分下的任何现有勘误表列表中。 - -要查看之前提交的勘误表,请转至[https://www.packtpub.com/books/content/support](https://www.packtpub.com/books/content/support) 并在搜索字段中输入图书名称。所需信息将出现在勘误表部分下。 - -# 盗版行为 - -在互联网上盗版版权材料是所有媒体都面临的一个持续问题。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上发现任何形式的非法复制品,请立即向我们提供地址或网站名称,以便我们采取补救措施。 - -请致电`copyright@packtpub.com`与我们联系,并提供可疑盗版材料的链接。 - -我们感谢您在保护我们的作者方面提供的帮助以及我们为您带来有价值内容的能力。 - -# 问题 - -如果您对本书的任何方面有任何问题,可以通过`questions@packtpub.com`与我们联系,我们将尽力解决该问题。 \ No newline at end of file diff --git a/docs/get-start-rvr/01.md b/docs/get-start-rvr/01.md deleted file mode 100644 index 1f1f1b9..0000000 --- a/docs/get-start-rvr/01.md +++ /dev/null @@ -1,416 +0,0 @@ -# 一、什么是真正的虚拟现实? - -您阅读本书是为了学习制作**虚拟现实**(**VR**),但什么是虚拟现实? - -这似乎是一个足够简单的问题,但答案却无处不在。大多数人认为虚拟现实意味着虚拟现实或交替现实。 - -这不是虚拟现实。 - -我认为这是因为虚拟这个词可以表示几种不同的东西。对于计算机科学家来说,虚拟这个词的意思是模拟它虚拟化的东西。换句话说,虚拟硬盘驱动器假装是硬盘驱动器。 - -虚拟对象的行为就像它是真实的,但它不是——通常,它比物理对象更灵活、更易于控制、修改和支持。在许多方面,它比物理对象更好。例如,虚拟磁盘的作用就像计算机磁盘。它可以存储数据。然而,这些数据可能在物理旋转磁盘、固态驱动器甚至内存中。虚拟磁盘可以调整大小,而物理磁盘只能复制到更大(或更小)的磁盘。虚拟磁盘更灵活。 - -有些人认为虚拟意味着几乎不可能。如果一辆特斯拉汽车经过,他们可能会说,“*这实际上是无声的!”*人们知道它并不是真的无声,但它比一辆大 V8 汽车经过时要安静得多。或者,*那个人是一个他们喜欢的人的虚拟圣人*。在这种情况下,它的意思是几乎或全部除了名字。 - -虚拟也可以指有美德的人。一个行为合乎道德的人是虚拟的,尽管这不是这个词的正常用法(应该是美德)。这个词就是从这里来的;在拉丁语中,虚拟意味着力量或美德。然而,在我们的例子中,我们指的是看似真实,却并非如此的东西。 - -我认为这是对虚拟现实的误解。人们认为这几乎是真的。很多人认为虚拟现实还没有出现,因为它看起来不像真实世界。要想通过虚拟现实耳机看到真实世界,还需要很长一段时间;其他感官,尤其是触觉和味觉,可能需要相当长的时间才能被模拟出来。 - -然而,这不是重点;虚拟现实的关键不在于它几乎是真实的。关键是,当你身处其中时,它似乎是真实的,即使它看起来一点也不像现实。 - -我要再说一遍,因为这是一个重要的区别。虚拟现实,或者说增强现实,不需要是近乎真实的,但当你身处其中时,它会*看起来*真实(即使它看起来一点也不真实)。 - -在本章结束时,您将学习: - -* 什么是虚拟现实及其工作原理 -* 虚拟现实的一些历史——这不是新的,这项技术已经有 50 多年的历史了! -* 用户代理-通过控制器与世界互动 -* 渲染硬件 -* 如何观看虚拟现实 -* 耳机类型 - -# 什么是虚拟现实及其工作原理 - -我们有很多感觉。为了让我们觉得另一种现实是真实的,我们需要用这些感官来愚弄大脑。大多数虚拟现实系统使用两种:视觉和声音;触摸也被使用,但不是完全伸出手去触摸某人的感觉(尽管人们正在努力!) - -Tor Nørretranders 用计算机术语汇编了有关感官及其相对带宽的数据。这有点像是比较苹果和机油,尽管这有助于了解它如何应用于虚拟现实。 - -![](img/cfa44ce3-7099-467c-b891-646155bb9608.png) - -所以,我们可以看到,如果我们让你看到的东西实际上是真实的,我们也许能够说服大脑它是真实的。然而,仅仅在我们面前放一个视觉屏幕并不是完整的答案。 - -给予某人深度的感知是最重要的答案。 - -这是一个相当复杂的主题,但显示物体深度的主要方法是立体深度感知。还记得那些 ViewMaster 玩具吗?以下是一个示例: - -![](img/4217a4c0-12a7-4144-898f-71607c076030.jpg) - -你放了一个有左眼和右眼图像的磁盘。左侧和右侧的图像看起来几乎相同,但它们代表了如果你站在那个位置,右眼和左眼会看到什么;由于视差的原因,每一个都略有不同。从磁盘上,我们可以看到左眼和右眼的图像。前面的视图母版中的镜头将您的眼睛聚焦在图像上。 - -![](img/b9fb8c36-80f0-43f6-a3a1-2d524ea24c42.jpg) - -你的大脑会看到这两幅图像,并将这些图像融合成看起来真实的东西。这使用了一种称为**立体深度感知**的深度感知技术 - -是的,View Master 是早期的虚拟现实查看设备! - -现在,这里到底发生了什么?立体声是如何工作的? - -当你看某物时,你的眼睛之间的透视和分离会使你的眼睛以不同的方式聚焦在距离较近的物体上,而不是距离较远的物体上。在这张图中,黄线表示我们对近处物体的视线,橙线表示对远处物体的视线。请注意,黄线之间的角度大于橙色线的窄角度: - -![](img/aadcbec8-6fea-4116-9ff4-b959cd372051.jpg) - -一个友好的机器人借给我们她眼睛的下半部分来制作这张图片(这就是为什么它会显示电路板)。你真正的眼睛构造得有些相似;为了便于说明,我省略了光线以及它们落在眼睛后面的位置。 - -通过黄线和橙线之间的角度差,你的大脑会自动判断你的眼睛是指向近的还是远的物体。 - -这只是我们大脑用来区分深度的一种方法。另一个对虚拟现实同样重要的是*视差*的使用。 - -视差指的是这样一种方式,不仅左右安卓眼睛的指向不同(就像你的眼睛,当它们连接到你的头上时),而且每只眼睛看到的是相同对象的稍微不同的视图。如果你左右移动你的头,即使只有一只眼睛也能做到这一点,这也是单目视觉的人感知深度的方式(以及其他方式)。 - -这是您的左眼看到场景的方式: - -![](img/3c67b4cd-39b4-4c73-94a9-795aff0292df.jpg) - -这是右眼看到同一物体的方式: - -![](img/afe5a296-acfd-49da-afed-1b54bfa9b940.jpg) - -视差指的是当用另一只眼睛观察时,或者当你从左向右移动头部时,距离较远的物体比附近物体的左右方向要小的方式。我们的大脑(以及动物的大脑)会本能地把这些看得更近/更远。 - -红色立方体或者紧挨着蓝色立方体,或者紧挨着绿色立方体,这取决于眼睛看到的图像。你的大脑将整合这一点,再加上如果你把眼睛从一边移到另一边,立方体是如何移动的,这也会给你一种深度感。 - -Don't despair if you are in the percentage of the population that do not perceive 3D movies. They strictly rely on stereoscopic depth perception and do not take parallax effects into account; they are pre-recorded. - -With *true* VR (computer generated or light field based 360 video), if you move your head, you will see the parallax effect and the VR can seem real just like someone with stereoscopic depth perception sees. - -我有单视功能,因为我有一只近视眼和一只远视眼,VR 对我来说非常有用。你的里程可能会有所不同,但如果你不喜欢 3D 电影,试试 VR(再说一次,我真的很喜欢 3D 电影)。 - -当你从右向左移动头部时,即使你有一只眼睛,视差深度感知也会起作用。 - -还有一种方法,你们的大脑将用来确定物体的深度——聚焦。(事实上,除了列出的方法之外,还有许多其他方法,例如远处对象的蓝移,如山脉,以及其他效果)。聚焦现实世界中的某个对象将使该对象和距离大致相同的其他对象显示在焦点上,而距离更远和更近的对象将显示模糊。有点像这样: - -![](img/276d35d8-68c3-4f5e-b1e2-ce0fd4fca3f0.jpg) - -当前的 HMD 不能准确地显示聚焦效果。你看到的是一个小屏幕,通常在你面前有一个大约 5 英尺的固定焦点。所有的物体,无论是近的还是远的,都会像屏幕上显示的一样被聚焦。这可能会导致轻微的 VR 不适,称为适应-收敛冲突。基本上,如果你聚焦在远焦立方体(鲑鱼色立方体),你的眼睛仍然会聚焦,就好像鲑鱼立方体位于红色立方体所在的位置一样;然而,你的眼球将立体地瞄准,就好像它位于它应该在的地方一样。这种效果在非常接近的对象中最为明显。 - -The accommodation-vergence conflict is most severe with close objects - so try not to have anything, such as a GUI, located too close to the user's location. You will reduce sickness this way. - -This means you may need to float GUI elements out into the room instead of having them very close. This may cause overlapping UI elements.  - -虚拟现实设计具有挑战性。我期待着你的设计! - -# 立体和视差在虚拟现实中的应用 - -早在 1968 年,伊万·E·萨瑟兰(Ivan E.Sutherland)就首次观察到具有立体深度感知的物体,当用户头部移动时,这些物体似乎位于空间中(运动视差),这些物体似乎是真实的。 - -他和鲍勃·斯普鲁尔开发的系统,通常被称为*达摩克利斯之剑*,只是在空气中显示了一些发光的线条,但: - -*"Even with this relatively crude system, the three dimensional illusion was real."* *-Ivan E. Sutherland, AFIPS '68 (Fall, part I) Proceedings of the December 9-11, 1968, fall joint computer conference, part I: [http://bit.ly/2urAV5e](http://bit.ly/2urAV5e)* - -在这个例子中,真实意味着尽管完全缺乏真实的渲染——只是一个发光的立方体——但人们认为它是真实的。这是由于立体渲染和视差效果。人们可以转头左右移动一点。 - -他们发明了第一款虚拟现实耳机,或**头戴式显示器**(**HMD**)。 - -被广泛认为创造了虚拟现实这个术语的人 Jaron Lanier 说: - -“It's a very interesting kind of reality. It's absolutely as shared as the physical world. Some people say that, well, the physical world isn't all that real. It's a consensus world. But the thing is, however real the physical world is – which we never can really know – the virtual world is exactly as real, and achieves the same status. But at the same time it also has this infinity of possibility that you don't have in the physical world: in the physical world, you can't suddenly turn this building into a tulip; it's just impossible. But in the virtual world you can …. [Virtual Reality] gives us this sense of being able to be who we are without limitation; for our imagination to become objective and shared with other people.” -- Jaron Lanier, SIGGRAPH Panel 1989, Virtual Environments and Interactivity: Windows to the Future. [*http://bit.ly/2uIl0ib*](http://bit.ly/2uIl0ib) - -一位名叫梅尔·斯莱特(Mel Slater)的研究人员对这一概念进行了进一步的研究,创造了更多的术语“存在性”和“合理性”。有些人把这一切称为**沉浸**。屏幕上的 3D 图像并不像你戴着头盔时那样引人注目,你唯一能看到的就是构建的 3D 世界。由于音频和视频提示,您会感觉到一种存在感,即使渲染与真实世界不同。似是而非意味着,即使你在现实世界中所看到的并不完全是真实的,你所看到的也有规律和有效性。 - -仅限于看到 HMD 中的内容,以及视差和立体视图,以及任何音频(如果做得好,声音非常重要)的组合将让你沉浸在虚拟现实世界中。有了所有这些东西,即使图形不是真实的,你也会感到沉浸其中,并且变得真实。更多学术细节见[http://bit.ly/2vGFso0](http://bit.ly/2vGFso0) ,尽管我将在本节中对此进行更多解释 - -这确实有效。 - -虚拟现实并不一定要看起来像现实,但它看起来似乎是真实的。例如,看看游戏*Quell4D*: - -![](img/e5e64945-8b18-47cf-973e-aed4515db4f7.png) - -图形是块状的,图像看起来一点也不像现实。然而,当古老的三鼻子大象 Necro 萨满向你走来时,你会感到害怕。它们似乎是真的。对你来说,当你玩游戏的时候,它们是绝对真实的,这意味着如果你不认真对待它们,你(游戏中)的人就会死。 - -虚拟现实模拟火灾会让大约 10%的人惊慌失措地走出房间,尽管火焰看起来与真实火焰完全不同。 - -虚拟现实在这里。我们不必等到图形变得更好。很多人都这么说虚拟现实,但这是因为他们还没有尝试过虚拟现实,并且正在对虚拟现实应该是什么进行假设。 - -潜水吧,水很好! - -So, Virtual Reality is something that will seem real, not something that necessarily looks real (but it helps if it does!). - -You do not have to wait until better graphics come around.  - -# 如果虚拟现实看起来不是 100%真实,为什么它能工作? - -我们的眼睛可能是向我们展示世界组成的最重要的感官。如果我们用这些图片代替图片,让某人沉浸其中,他们就会开始变得真实。当你第一次进入虚拟现实时,你最初的反应是,“*看起来不是真的”*但是有了一个好的虚拟现实设置,你会想到*“哇,那是真的”*,即使你知道你在看的基本上是一个电脑游戏。 - -A fast frame rate (speed of display) and enough resolution will trick your brain into thinking what it sees visually really does exist. This is a powerful effect that most, but not all, people will have when immersed in such images (not everyone with normal eyesight sees 3D movies either). - -事实上,现实感是如此之好,以至于人们可以通过观看虚拟现实来生病。这是因为你的眼睛可能会说那是真的,但你的其他感官,比如你的内耳,会说我们不会在空中跳 10 英尺。如果你的眼睛认为你在空中弹跳,你的腿部肌肉(本体感觉)说你在地面上,你的皮肤说你感觉不到风,你的内耳说你在向前飞行时没有倾斜,那么你的思维将在一个非常深的层次上混乱。 - -当你的感觉强烈不一致时,你的身体有一个防御机制。它认为你中毒了;结果,你的身体会感到恶心,甚至可能生病。你的身体担心你的眼睛看不到身体其他部分的感觉,所以它可能会试图清除你胃里的所有东西,以防你吃的东西中毒。 - -是的,一点也不好玩;不同的人会有不同的反应。 - -然而,并不是所有的虚拟现实都能做到这一点!一般来说,结构糟糕的虚拟现实会给你这种感觉。关于这一效应的学术论文已经发表。这本书将把这些讨论总结成一些简单的规则,让你的虚拟现实对人们来说更舒适。 - -虚拟现实的另一个重要方面是你可以与之互动(现实本身)。这带来了机械上的困难;并非每个人都拥有 3D 控制器。我们将在*用户代理部分介绍这一点——通过控制器*与世界互动。真正的虚拟现实可以与之交互,即使它像凝视检测一样简单——注视某物(凝视)并发生事情——运动发生,你被传送,动画播放。 - -# 其他类型的虚拟现实;AR、XR、SR/FR - -还有另一种图像类型,有时称为 VR,即**360 视频**。有专门的视频播放器,可以在各个方向录制。复杂的软件将不同的摄像机输入缝合在一起,形成一个视频流,播放软件将投射到你周围。当你回头时,你似乎改变了你在电影世界中的观点。就好像你在现实世界里,环顾四周,想看什么就看什么。 - -360 视频看起来可能比大多数计算机图形生成的虚拟现实要好,但对我来说,这不是现实,因为你充其量只是一个虚无的幽灵。当然,这个世界看起来很棒,但是你不能伸手去摸东西,因为它已经被拍下来了。360 视频和类似的系统超出了本书的范围。话虽如此,我确实认为 360 视频确实是一种有效的艺术形式,也是一种值得追求的东西——只是这本书没有涵盖。 - -请理解-我的意思不是轻视 360 视频,只是因为它不是*真实的*VR。(把那个倒数第二个字的发音,就像它周围有空中引号一样)。360 视频可以是非常温暖、激烈、情绪化的戏剧。你确实得到了一点存在感的暗示,视觉效果令人震惊。这是一个我们应该看到令人惊叹的艺术正在制作的领域,因为更多的人都熟悉它并了解其细节。 - -我正在为 360 视频提议一个新术语;**电影真人秀****FR**或**环绕真人秀****SR】**。(虽然没有人用真正的胶卷拍摄,但短语“filmed”的意思仍然是通过镜头来记录一些东西,但 SR 可能更好。你选择!) - -还有其他类型的虚拟现实。如此之多以至于有些人使用短语**XR**,这意味着(任何)现实;主要指 AR 和 VR。什么是 AR? - -HMD 由一些小型显示器和复杂的光学元件组成,当您戴上耳机时,可以看到立体 3D 图像。大多数 VR 头戴式耳机都有意将你置身其中时的世界隔绝开来,让你进一步沉浸在 VR 中。这是虚拟现实的一个重要组成部分,尽管有一种称为增强现实(AR)的虚拟现实,其中虚拟现实项目通过佩戴一种透明的 HMD 投影到现实世界中。虽然微软 Hololens 可能是最著名的,但有很多制造商。还有一款游戏*口袋妖怪 Go*,这是一种 AR。人们举起手机,手机上显示的图像是在现实的基础上分层的。这不是耳机,但仍然是 AR。口袋妖怪世界增强了现实。 - -虚拟现实系统也可以是世界上的系统上的**窗口,尽管这在今天并不通常被称为虚拟现实。换句话说,一个真实的、持久的 3D 世界,你坐在键盘旁,通过屏幕观看。在几年前的最后一次虚拟现实浪潮中,这被称为虚拟现实,尽管今天它已经很普遍,人们不再称之为虚拟现实。你可能听说过**魔兽世界**。** - -这是一种虚拟现实;虽然它不是(通常)在 3D 中,但它是一个在另一个现实中持续存在的世界。这也是一个完整的 3D 世界,你可以通过看你的屏幕看到;屏幕将你传送到一个虚拟现实中,因此它类似于 Windows on World 系统(尽管不是头跟踪) - -观看电影可以被视为虚拟现实的一种有效形式;你被带到了另一个世界,在很短的一段时间里,你感觉自己好像沉浸在故事中。电视是一种虚拟现实技术。 - -事实上,VR 一词的首次使用是指剧院。虽然今天很多人会说那不是虚拟现实,但他们花了很多时间观察其他现实,而没有注意坐在他们旁边的人。这怎么不是虚拟现实?你沉浸在*与星星共舞*中,但你认识其中任何一个吗?它们实际上是真实的。 - -然而,这并不是大多数人的想法。本书将使用现代(2014+)对虚拟现实的解释,即通过 VR 耳机或某种类型的 HMD 观看的东西。如今,术语 VR 通常指耳机或 HMD,并且通常与某种形式的手控制器结合使用。好的、有效的 HMD 现在都可以在市场上买到。现在是对虚拟现实感兴趣的好时机。 - -WebVR 的好处在于,我们仍然可以通过浏览器看到这些虚拟现实世界,而无需使用 HMD;这对于测试和没有硬件的人来说是非常好的。 - -WebVR 非常包容 - -# 虚拟现实的历史 - -大多数人也认为虚拟现实是相当新的,但它实际上已经存在很长时间了,我指的是传统类型的带耳机的虚拟现实。第一个头盔显示器是由伊万·萨瑟兰和鲍勃·斯普鲁尔于 1968 年发明的。由于当时的技术,它又大又重,因此被悬挂在研究室的天花板上。它也只显示线框图像。由于它的大小,它被称为达摩克利斯之剑。它展示了一个简单的线框世界。当时的计算机速度不够快,无法显示出比几条发光的线条更复杂的东西。 - -90 年代末,个人电脑开始以足够快的速度显示 3D 世界,出现了新一轮的虚拟现实。我参与了这些努力;我当时正在为 CompuServe 开发一个 3D 环境,当时正是这个地方。 - -你可以去商场,用一个昂贵的 HMD,在一个共享的虚拟世界中与最多四个人在线。这被称为基于位置的娱乐,因为系统庞大且昂贵。今天,你也可以去虚拟现实商场体验硬件,但令人兴奋的是,虚拟现实的许多系统对于家庭爱好者来说是非常便宜的。 - -# 用户代理-通过控制器与世界互动 - -HMD 不是一切,尽管它肯定是最重要的部分。能够看到一个虚拟现实世界是很棒的,但在某个时候你希望能够与之互动。如果世界是静止的,你会觉得自己像一个虚无的幽灵。当你可以与世界互动时,这就是虚拟现实。 - -最终,像全套触觉(物理反馈)和身体跟踪,以及复杂的软件,将允许我们接触虚拟世界。这是未来值得期待的事情。 - -目前,我们通常与世界互动的方式是通过各种手持控制器。不同的控制器具有完全不同的功能和要求。高端(但消费者仍然可用)VR 设置的控制器,如 Rift 和 Vive,与移动 VR 控制器的工作方式有很大不同。我们将首先讨论高端系统,然后讨论移动 VR 控制器。 - -# 适用于 PC、Mac 和 Linux 的高端控制器 - -有了**PC VR**,例如 HTC Vive 或 Oculus Rift,控制器提供了与虚拟现实世界交互的非常重要的能力。这些控制器在 3D 空间中进行跟踪,以便软件知道它们在哪里。开发人员可以将其编码为手、枪等。这让你能够接触到周围的世界——这对于让你正在与之互动的虚拟现实成为你可以与之互动的东西非常重要。 - -为此,Oculus 和 Vive 控制器都需要外部跟踪硬件。在 Vive 中,这些灯塔或 VR 基站位于 VR 区域的角落。(这里有一个图表,可在[找到)http://bit.ly/VIVEManual](http://bit.ly/VIVEManual) )。这些小而不显眼的立方体发出红外跟踪信号,控制器和耳机接收并使用这些信号在真实的 3D 世界中精确定位它们。在 Rift 中,有两个或三个传感器也可以跟踪设备,为它们提供真实的位置: - -![](img/9ff1f919-d062-4e40-a9eb-e90f7061ae6a.jpg) - -基站和跟踪硬件对 HMD 本身也非常重要 - -这种对真实世界位置(实际头部/手)的跟踪使移动、转动头部、移动手/控制器看起来真实,因为耳机和控制器的位置、方向和移动在真实 3D 空间中被精确跟踪,一旦软件向用户显示虚拟现实世界,任何头部运动都是真实的。 - -实际上,这意味着 PC 控制器似乎就在您看到它们的地方。我在技术演示中第一次体验 HTC Vive 是令人惊讶的——我戴上了 HTC Vive 耳机,在虚拟世界中,看到了我面前的控制器。我希望在控制器到达我认为应该到达的位置之前,我会一直摸索着。我伸出手,我的手指感觉到控制器正是我眼睛看到的地方——通过 HMD。 - -我上瘾了!虚拟世界真的是虚拟现实!我看到的幻影控制器是*真实的*,尽管我知道我看到的是我面前的一个小屏幕。 - -它们是如何工作的? - -# 宏达电万岁 - -HTC Vive 在覆盖区域的两端使用两个称为**基站**或**灯塔**的方形小立方体。它们发出超过 120 度的红外光;这意味着如果它们在一个角落里,它们可以离角落几英寸远,并且仍然覆盖着墙壁(否则,你必须在墙上挖一个洞,把灯塔放在正确的位置!) - -通常,您将两个基站安装在房间的对侧,相距约 16 英尺或 5 米,高于头部高度约 2 米或 6 英尺 6 英寸。如果你高一点没关系——把它们骑得高一点! - -基站也可以用合适的适配器或定制支架安装在麦克风支架上。并不是每个人都有一个大客厅,所以这些安排可能有助于适应它。 - -Vive 也可用于坐姿配置,尽管真正的点是所谓的**房间比例**。 - -房间比例虚拟现实意味着你可以在虚拟现实世界中四处走动,就像你在真实世界中走动一样。不需要远程传送或其他技巧。当然,该区域需要远离家具,这是 VR 的一个普遍问题;不是每个人都有一个大房间,他们可以清除。 - -如果你离得太近,Vive 会在你的空间边缘显示边界或守卫,以确保你的安全。 - -Make your room bounds slightly less than the actual room, if it's a wall or other area. If it's a couch or chair, you can go right to the end of the chair. - -We do this so you don't hit your arm on the walls. This is easy to do if you are standing up against the wall, but still in the virtual world and therefore can't see the wall, and you swing your arm, your hand won't go through the real wall!   - -走到沙发边上很好,因为在你的手撞到墙上之前,你的小腿会撞到沙发上。实际上这并不是什么大问题,因为在你靠近之前你会看到警卫。请注意 Vive/Steam VR 教程! - -HTC Vive 的工作原理是拥有一些**惯性测量单元**(**IMU**s),用于检测 HMD 以及控制器的位置。这些 IMU 会漂移,因此基站会有一束红外光束扫过房间。当控制器、跟踪器或 HMD 检测到这些光束时,它们会自动重新居中。这种重新定心是完全无法检测到的。这种系统的优点是,即使控制器从基站或灯塔中的一个看不见,虚拟现实系统仍然知道该项目所在的位置和指向的位置 - -总体效果是精确性和存在性,尽管主要效果是稳定性。如果双手交叉,控制器暂时离开基站,控制器不会失去锁定。 - -Try not to put your VR space in an area with a lot of windows or mirrors. - -The Infrared beams can reflect off of them, leading to instability. - -# 虚拟现实眼镜 - -**裂痕**最初只是作为一个耳机出现,没有控制器。它的初始基站是两个摄像头,你放在桌子的左右两侧;它们指向 HMD 并用于在世界上定位它。 - -不久之后,Rift 增加了第三台摄像机的功能;有了三个摄像头,你就可以进行房间比例的虚拟现实。他们的定位与 Vive 略有不同;查看 Rift 文档以获得最佳定位。 - -Be careful with cables. As I wrote this book, the Rift is cabled directly into the back of the PC. If you trip over the cables, you could yank them out of the PC fairly hard, leading to damage. - -The Vive has a breakout box, so if you trip over a cable, you'll hopefully pull it out of the box.  -Don't trip over the cables. - -这本书的目的不是要分析为什么生命或裂痕比另一个更好或更坏;它们的工作方式大致相同,基站/摄像机帮助控制器和 HMD 跟踪它们的位置和旋转。以下是一个典型的设置: - -![](img/473431da-a972-418b-8599-25766ad16616.jpg) - -其中,Vive 基站安装在墙上;我们有一台台式 PC 和一个 VR 用户,他们可以像观看真实的 3D 模型一样观看 3D 模型。VR 用户持有两个 Vive 控制器;虚拟图像中有一个 Xbox 风格的游戏控制器。 - -此图还显示了 Oculus Rift 3 摄像机跟踪器系统。它们是浅灰色的物品,位于屏幕的左侧和右侧,以及沙发背面(就在我们前面)的 credenza 上。 - -没错,控制机器人就是用户。她不需要头盔显示器;系统将视频直接传送到她的眼睛。虚拟对象是看起来坐在桌子旁的人 - -上图是沙发前面的第三个人可能看到的场景视图。 - -灯塔看到的其实有点不同,但很有趣。它们实际上有几个红外条扫过视图,控制器看到这些线在视图中跟踪。当它们这样做时,控制器(和 HMD)将重新同步其惯性跟踪的定位。这意味着,即使控制器不在基站的视野内,它仍会保持跟踪,尽管您不想将控制器隐藏太长时间。惯性跟踪系统会漂移。漂移的视觉问题是,你的手臂似乎会慢慢远离你的身体——这显然是非常令人不安的。Vive 灯塔和裂谷相机阻止了漂移的发生。灯塔投影的视角约为 120 度。如果在灯塔的后面,最右边的灯塔上有这样一个视野的摄像机,那么它就会看到: - -![](img/fd9a35b9-fb93-47d7-8dd9-81c9458819d4.jpg) - -通过这座灯塔,你可以看到控制器和头盔显示器。然而,有一个问题。注意红色的圆圈——左边的大镜子实际上是一台巨大的电视,但它很闪亮。因此,灯塔的红外光束将从灯塔上反弹,控制器将感应到两个光束:一个直接光束和一个反射光束。 - -这可能会导致 HMD 和您的视角跳跃,或者您的控制器莫名其妙地移动。 - -Avoid shiny objects, mirrors, and windows in your VR room. - -You might need to draw drapes, or even throw sheets over TV's, glass china cabinets, and the like. - -Art requires sacrifice! - -从另一座灯塔,其中一个控制器被封锁,但仍然通过其内部惯性跟踪和另一座灯塔 100%跟踪。 - -![](img/d7462688-59fc-490b-86fc-425f775a7acf.jpg) - -# 移动虚拟现实 - -对于**移动 VR**来说,还有谷歌白日梦和三星 Gear VR 控制器。由于使用了更简单的硬件,从而使价格更合理,因此没有完全 3D 跟踪。 - -在移动虚拟现实中,由于没有 Vive 和 Oculus 都具有的房间跟踪外部传感器,虚拟现实控制器的跟踪不那么精确。在实践中,它们看起来同样真实,但会周期性地漂移。就好像你的手在没有控制的情况下慢慢向右移动。因此,Mobile VR 有一个重置控制器按钮,可以将控制器移动到预定义的位置,例如靠近臀部的位置。您的手可能会被直接伸出,但如果您按下 Home(主页)按钮,VR 显示屏将显示您的手现在位于您的臀部。 - -这可能需要一些时间来适应。这种设置有很多优点;它更便宜,需要更少的外部硬件,而且世界上有更多这样的系统。然而,PC 硬件确实提供了更好的虚拟现实体验。 - -移动控制器的另一个缺点是只有三个**自由度**(**自由度**)。这意味着它们跟踪倾斜、偏航和滚动,但不跟踪位置;如果您将控制器平放在左侧,则在游戏中,您的控制器根本没有移动。这就是为什么你不能用移动控制器抓取东西。Vive 和 Rift 都有 6 个自由度控制器,所以你可以移动它们并抓取东西。 - -# 渲染硬件 - -为了避免 VR 疾病,您需要**快速帧速率**。帧速率是多少?这是您的计算机在屏幕上生成图像的速度。当然,这在很大程度上取决于场景的复杂性;展示一个立方体和一个盒子要比展示洛杉矶所有的建筑快得多。 - -当然,当你设计你将要实现的虚拟现实世界时,你可以控制这一点。 - -必须实时生成每个图像。大多数虚拟现实耳机的频率为 90 赫兹。赫兹指的是每秒的频率(以周期为单位),在这种情况下,指的是每秒的帧数。 - -虚拟现实的困难在于,任何东西都不能降低帧速率。如果必须加载某个内容或获取某个网页,如果您稍微降低帧速率,人们会感到头晕目眩。 - -有两种方法可以加快帧速率。一个是场景复杂度较低,另一个是速度较快的计算机。 - -电影《大白鲨》中的一句经典台词是,当他们发现鲨鱼比他们想象的要大得多时,鲨鱼撕碎了他们的船。罗伊·谢德说,“*你需要一艘更大的船* - -要查看虚拟现实,您需要一台更大的计算机。 - -幸运的是,计算机的速度越来越快。说到电脑,我们还指高端智能手机。对于我们在这里建立的世界来说,一部速度相当快的智能手机应该没问题。 - -场景的复杂性有点进退两难;您想要一个丰富、详细的虚拟世界,但也希望虚拟世界能够快速渲染。如前所述,快速渲染是指每秒 90 帧(更新)。您还需要了解硬件支持方面的目标受众。它们都在高端个人电脑上,有一对千美元的显卡吗?(有点过分了;我在这里强调一点。)或者它们是在去年的手机模型上,带着一个 10 美元的硬纸盒和一些镜头吗?如果您了解您的潜在目标受众,您可以开发一个与他们的系统配合良好的 VR 应用。 - -美国海军陆战队有句谚语:*“训练如你所愿*。*在第二次世界大战期间,他们在加利福尼亚州南部海岸进行两栖登陆作战。当他们在太平洋战争期间不得不这样做时,他们没有计划建造珊瑚礁。因此,他们制定了一个原则,即你应该在与他们预期作战的相同或相当相似的环境中训练他们。* - - *虽然一个好的虚拟现实体验(希望)不是生死攸关,但这仍然是有价值的建议。如果您认为您的 VR 应用的大多数客户或消费者将使用去年的手机,那么请使用去年的手机进行测试。如果你认为它们会出现在高端 PC 上,请用高端 PC 进行测试。 - -不要以为,如果你的虚拟现实应用速度慢,客户会有更好的电脑,一切都会好起来。买一些和他们使用的相似的东西,然后你会比你的客户先经历恶心和眩晕,然后重新编码或简化你的场景以足够快。 - -多少硬件足够?为此,您应该参考您计划瞄准的耳机的最低规格。由于这种情况可能会发生变化,所以我不会在本书中对其进行总结,但不同的虚拟现实制造商给出的指导方针是很好的建议。 - -你可能需要一台更大的电脑(或手机);这是你为成为早期采用者所付出的代价! - -# 如何查看虚拟现实? - -要查看虚拟现实,您需要某种类型的耳机或 HMD。在过去,虚拟现实的特点是在 2D 屏幕上显示 3D 图像。实际上,当时的虚拟现实意味着用任何设备观看任何 3D 程序——基本上像平常一样坐在你的电脑前,但这并不是真正的身临其境。如今,虚拟现实意味着使用 HMD/耳机;因此,要查看一个,您需要一个耳机。 - -具有讽刺意味的是,React VR 在浏览器中也可以作为 3D 世界使用,可以用来制作具有视差功能的网页,尽管这有点过分了。 - -# 虚拟现实可能很危险 - -你可能认为,这是相当安全的。然而,一个 VR 耳机附带了 33 页的警告。*阅读它们*。 - -大多数警告都是常识性的,例如,如果你靠近物体或人,不要挥手。戴上眼罩,你真的可以拍你的手了。从哲学上讲,我不相信保姆状态,但你真的会因为虚拟现实而受到伤害。想象一下,如果有人给了你一个眼罩,让你戴上,然后在你的房子里走来走去。你可能有点不舒服。 - -这就是我们在这本书中要做的,除了你会在惊奇和兴奋的状态中徘徊的额外皱纹。YouTube 上有很多视频,人们在视频中撞墙、撞墙、撞倒灯等等。它们看起来很傻,但当你戴上 HMD 时,你完全沉浸在虚拟世界中,根本不想动手。所以,确保你离开房间,并警告朋友不要进来。 - -这包括你的毛茸茸的朋友。把宠物挡在你的虚拟现实区域之外比较困难,但这是一个好主意,因为他们不会理解 HMD 分散了你的注意力,你也看不到它们。如果你能找到一种方法,最好是防止它们被踩到脚下,否则你可能会无意中踩到它们。 - -虚拟现实是安全的;负责任地使用。 - -# 虚拟现实耳机选项 - -对于 WebVR,有几个选项。我会把它保存在最简单、最实用的耳机里。您当然可以使用**开源虚拟现实**(**OSVR**),这实际上是一个硬件平台,但您需要弄清楚使用什么浏览器,等等。一些术语,如凝视,将在 UI 部分的后面介绍。现在,凝视移动意味着您需要凝视一些东西,以便 UI 将您移动到那里,或者让您选择一个对象,通常情况下 - -以下是各种主流的 WebVR 选项(您可以在[上阅读)https://webvr.info/](https://webvr.info/) : - -| **型** | **控制** | **动作** | **成本** | -| Gear VR(移动) | 1 台手持式头盔显示器 | 凝视/触摸板 | 中等的 | -| 白日梦虚拟现实(移动) | 1 个手持设备 | 凝视/触摸板 | 中等的 | -| 硬纸板/其他耳机 | 无(单击“可能”) | 凝视选择 | 低的 | -| 宏达电万岁 | 跟踪,2 个控制器 | 走来走去 | 高的 | -| Oculus Rift 2 照相机 | 键盘/操纵杆 | 凝视选择 | 高的 | -| Oculus Rift 3 照相机 | 跟踪,2 个控制器 | 走来走去 | 高的 | - -# 耳机类型 - -从广义上讲,它们可以是连接 PC 的耳机或移动耳机。一些类型的独立耳机,如 Hololens 或 Vive 独立 VR 耳机,包括一台完全工作的 PC,因此它们实际上更像一个移动耳机,但不需要 PC。 - -# 移动耳机 - -移动耳机实际上只是用你的手机来显示数据,让你进入虚拟现实世界。因此,性能完全取决于手机的性能。 - -这是一个越大越好的时代。 - -但是有一个限制;有些耳机使用平板电脑,但它们非常笨重,与较小的移动设备相比没有任何优势。 - -当您使用移动耳机时,您会遇到电池寿命、重量和控制问题。市场上有各种各样的虚拟现实控制器,以及三星 Gear 虚拟现实(Samsung Gear VR)和谷歌白日梦(Google Daydream)等捆绑选项,其中包括一个控制器以及一个手机壳。 - -这些捆绑包的好处在于,手机通常经过认证可以正常工作,并且软件易于使用。您可以构建自己的虚拟现实耳机/控制器组合。 - -移动耳机也可以像一个盒子一样简单,里面装着一些镜头,尽管实际上在光学元件的尺寸和细节上有很多数学运算。最常提到的是谷歌纸板;谷歌不直接销售,但公司可以实现硬纸板查看器。还有一些非官方的硬纸板和一些价格合理、构造更好的支架,你可以把手机放进去。 - -一般来说,它们大多数没有传感器。有些有一个小杠杆,可以触摸屏幕,允许一些控制而不是移动。 - -您也可以购买单独的蓝牙控制器,尽管它们很可能没有三维定位。我们在[第 11 章](11.html)、*野外漫步*中介绍了不同类型的控制器。 - -有些虚拟现实耳机可以配眼镜,有些则不能,这在很大程度上取决于你的脸型大小、你使用的眼镜大小以及你的确切视力问题。我有一只近视眼和一只远视眼,并且不需要眼镜(两只眼睛都不需要),但是你的里程数可能会有所不同。我强烈建议在购买前试用耳机,或从有良好退货政策的来源购买。 - -在高端(移动耳机),有三星 Gear VR 和谷歌 Daydream。它们提供了一个构造良好的耳机,您可以再次将手机放入其中,还提供了一个单独的控制器。 - -控制器是其中最重要的部分(尽管耳机也值得一试,因为它比最好的硬纸板观众都舒适得多)。Daydream 和 Gear VR 捆绑包中的控制器都是蓝牙,即无线的,并且有点跟踪。 - -他们有传感器,可以检测运动,但不能精确定位在太空中。因此,它们上面有中心按钮。这是因为这些装置中的三维位置传感器类型会随时间漂移。在虚拟现实世界中,你的手/控制器/枪(或控制器具有的任何视觉表现)似乎会远离你,甚至在你身后移动!这可能非常令人不安。如果发生这种情况,只需使用适当的按钮重新居中控制器即可。 - -高端 PC 设置有不同类型的跟踪,通常不需要重新定心。但是,请注意,它们需要初始校准/设置,也可能存在跟踪问题。 - -Notes on GearVR A few things with the GearVR I did a little wrong. There's an extra elastic strap that I thought was for slack or something; its not. They tell you to put the straps on, but neglect to mention this extra strap is to hold the controller. Flip ahead to the controller part before putting the straps on the headset. - -The controller should actually be the part you fiddle with first. You need to pair it and carry out some downloads, and that can't really be done when the headset is on, so do this part first. - -# PC、Mac 和 Linux 耳机 - -大多数人认为选择 PC 耳机是在 HTC Vive 和 Oculus Rift 之间,但是有几十种或数百种 PC 类型的耳机。 - -它们的性能取决于您的电脑性能。伙计们,这就是 Mac 电脑有点缺点的地方;你需要一个快速的视频卡,而 Mac 的速度通常足以支持图形和一些游戏,但不能支持虚拟现实。不过,苹果公司已经推出了 VR 准备好的 PCs.,当你决定用什么平台来做 VR 的时候,考虑一下这一点。 - -正如本文所述,Mac 对 Oculus Rift 或 HTC Vive 的支持充其量是实验性的,因此步骤和示例将假定您使用的是 PC。Linux 支持承诺用于几种耳机,但在本书中,它充其量是实验性的。如果您使用的是 Linux,则需要检查文档和/或尽可能遵循 Windows 示例。 - -大多数 React 虚拟现实演示的几何结构比许多虚拟现实世界都简单,因此它们将在相对较小的硬件上运行。请向耳机制造商咨询最低要求;不要以为你能以低于最低限度的价格过活。你会生病或者有不舒服的经历。 - -在整个市场中,我们将在本书中介绍两款耳机;HTC Vive 和 Oculus 裂谷。如果你有一个不同的耳机,样品应该可以正常工作,但你可能需要稍微摆弄一下。 - -一般来说,PC、Mac 和 Linux 耳机将与 Firefox 或实验浏览器[Servo.org](http://bit.ly/VRServo)配合使用。Chrome(Chrome)的实验版本也可用于查看 WebVR。请参见[webvr.info](http://bit.ly/WebVRInfo)上的完整最新列表。 - -# 总结 - -在本章中,我们讨论了虚拟现实,它为什么工作,以及它到底是什么(双关语)。我们还简要介绍了虚拟现实硬件和软件,介绍了如何进入虚拟现实世界。 - -请记住,即使您没有昂贵的 HTC Vive 或 Oculus Rift,您仍然可以在台式 PC 上查看 WebVR - -接下来,我们将介绍如何在非常高的级别上编程 VR。构建 VR 软件应用有很多不同的方法,我们将介绍不同的方法及其优缺点。您将阅读不同软件包的概述,以及 WebVR 的优势。因为这本书是关于 WebVR 的,所以我们将介绍 React VR、Node.js 和其他工具的安装,以开始实现您自己的现实——真的!* \ No newline at end of file diff --git a/docs/get-start-rvr/02.md b/docs/get-start-rvr/02.md deleted file mode 100644 index 0e67e61..0000000 --- a/docs/get-start-rvr/02.md +++ /dev/null @@ -1,255 +0,0 @@ -# 二、平面与超越:虚拟现实编程 - -在上一章中,您了解了虚拟现实是什么以及它可以是什么。程序员和开发人员(像你一样)是如何创建这些虚拟世界的?我们发现要做好这件事很难。我们必须保持快速的帧速率和适当的立体渲染。我们如何快速轻松地做到这一点?请继续阅读,并了解如何操作。 - -在本章中,我们将介绍以下主题: - -* HTML 和常见编程方法,如 Node.js、JavaScript 和游戏引擎 -* 反应库 -* 图形库,用于显示二维和三维图像 -* 如何安装所有这些软件,以便我们可以开始编程 - -# HTML 和超越 2D internet 的方法 - -在 web 开发的同时,早期的 HTML 语言也发生了巨大的变化。良好的网页体验通常不仅仅涉及 HTML。添加更多交互性的方法之一是通过 JavaScript。HTML、XML 和 JavaScript 的结合是交付 web 的很大一部分,包括应用,如 Google 文档或在线 Microsoft Word(也是免费的)。 - -然而,这些是平的。进入第三维度传统上采用高速软件,通常是用 C++编写的。随着计算机的速度越来越快,**图形处理单元**(**GPU**)占据了实际 3D 生成的大部分,描述 3D 游戏的语言也在不断发展。 - -目前在虚拟现实中编程的方式有很多种。在裂谷和 VIVE 显示的分辨率下,每秒生成 90 帧是一个挑战,因此大多数 VR 编程都是用高速语言完成的,这些语言直接面向金属或低电平,例如 C 和 C++。然而,游戏引擎,如 Unity、Unreal 或 Cryengine,可以为您做很多这方面的工作。 - -首先,你可能会想*我为什么要使用游戏引擎?我不是在写游戏*。更一般地说,这些引擎是为游戏而构建的,但不必只构建游戏。现代游戏引擎处理渲染(我们需要的)、物理(我们需要它来构建真实世界)、地形(用于室外场景)、照明(用于复杂渲染)、人工智能(用于填充我们的世界)、网络(用于构建多用户环境)和其他代码。这些都不一定是特定于游戏的,尽管所有不同的游戏引擎都有比企业数据可视化更适合游戏的术语。例如,在 Unity 中,基本 3D 对象被称为`GameObject`。所以,即使你没有写游戏,你也会有`GameObject`s。 - -目前,VR 软件的主要竞争者有: - -* Unity(由 Unity3D 提供,更多信息参见[http://bit.ly/UnityForVR](http://bit.ly/UnityForVR) ) -* 虚幻(由史诗游戏、虚幻锦标赛的制作者制作;更多信息参见[http://bit.ly/UnrealForVR](http://bit.ly/UnrealForVR) ) -* Cryengine(由游戏 Crysis 的制造商 Crytek 编写;更多信息参见[http://bit.ly/CrytekForVR](http://bit.ly/CrytekForVR) ) -* 木材厂(亚马逊提供;更多信息参见[http://bit.ly/LumberyardForVR](http://bit.ly/LumberyardForVR) ) - -许多游戏引擎也可以在移动平台上运行。使用游戏引擎的好处是,你可以*写一次,在任何地方*运行,这意味着大多数游戏引擎都支持移动和 PC。基本上,您先构建一个 PC 应用,然后更改构建设置并构建一个移动应用。现在,每个平台都有两个或多个不同的应用。 - -游戏引擎可能会有一个相当陡峭的学习曲线,尽管这仍然比编写自己的渲染代码容易。您确实需要构建整个应用,这可能会让人望而生畏 - -与当前最先进的网络编程相比,人们只想描述您想要看到的内容,而不是编写服务器端代码将网页发送到手机,也不是编写自定义应用下载并显示这些信息。 - -那么,为什么虚拟现实会要求你这么做呢? - -使用 React VR,您不必这样做。 - -您可以使用 JavaScript 构建自己的世界,而不是学习游戏编程引擎。您可以使用声明性组件构建 VR 世界和 UI,而不是构建渲染代码。实际上,你可以在更高的层次上描述你的虚拟现实世界,而不是一次只构建一个像素。听起来不是更有趣吗? - -# Node.js 和 JavaScript 的背景 - -大声说 Node.js。格松海特! - -js 是一个在服务器端使用 JavaScript 的开源系统。当然,这是 web 浏览器执行代码的主要方式。它早在网络的早期就被发明了,原因有几个 - -React 和 React VR 大量使用 JavaScript。将 React web 页面呈现到浏览器需要服务器端 JavaScript,这意味着 web 服务器不只是将文件发送到浏览器,而是在服务器端执行代码。Node.js 允许您使用与浏览器相同的语言编写服务器端代码。对于全堆栈开发人员来说,这是理想的,因为您可以沉浸在一种语言中。 - -# 让服务器做出反应 - -React VR 基于 React,该框架允许通过声明而不是编程来构建网页和交互式用户界面。您为应用中的每个状态构建视图,然后 React 将使用正确的组件来显示该应用。 - -声明性视图使您的代码更容易、更健壮、更易于修改和调试。 - -组件使用面向对象的封装概念,这意味着它们可以自给自足并管理自己的状态。然后使用这些组件来创建复杂的 UI。 - -React 允许开发人员创建随时间变化的应用,而无需不断刷新浏览器页面。它使用模型-视图-控制器设计模式/模板,并可与其他 JavaScript 库(如 Angular.JS)结合使用。 - -React 于 2011 年首次用于 Facebook 的新闻源。它于 2015 年 3 月开放源代码。 - -您可以在[找到关于 React 的更多详细信息 https://facebook.github.io/react/](https://facebook.github.io/react/) 。 - -# 图形库-OpenGL 和 WebGL - -本节将介绍一般的 3D 编程,但需要对不同的内容进行一些讨论。 - -**OpenGL**是显示图形的标准。没有进入 PC 与工作站的政治(古代的历史现在),这是一个标准,工作站供应商(SGI)率先标准化计算机图形和程序显示图形的能力。 - -还有其他 API,如 DirectX,它由 Microsoft 和许多 PC 游戏开发人员、CAD 软件和其他 PC 计算机图形支持。 - -严格来说,OpenGL 不是开源的;然而,该软件可以在不支付版税的情况下使用,并且有文档记录并且可以免费使用(公平地说,DirectX 也是如此)。 - -基本上,OpenGL 是软件显示图形的一种方式。在这种情况下,软件通常是指 C++(或其他可以调用本地库和 O/S 实用程序的语言)。 - -Vulkan 或多或少是 OpenGL 的继承者。它的级别低于 OpenGL,提供了执行并行任务和直接利用大多数智能手机和 PC 中 GPU 功能的更多能力。由于它是一种低级格式,您将听到更多关于 Vulkan 的计算机图形讨论,而较少关于 Web 图形的讨论。与 OpenGL 一样,它通常由编译的本机模式软件(C++等)使用。 - -**WebGL**是一个 JavaScript API,用于在 web 浏览器中呈现 3D 图形,无需插件。由于 OpenGL 的创建者 SGI 已不再营业,OpenGL 和 WebGL 现在都由 Khronos group(一个非盈利、成员资助的财团)支持、定义和销售。WebGL 通过 JavaScript 或其他浏览器支持的语言使用。 - -**three.js**是一系列 JavaScript 文件,使 WebGL 更易于编程。然而,这是一个相当大的下载量。 - -React VR 基于 three.JS 和 React 构建。 - -**A-Frame**是另一个 WebGL 前端;它与 React VR 有一个类似的概念,这意味着它是声明性的、高级的,并且构建在 three.js 上。您不必创建点并将它们连接起来以形成立方体;你只需要声明一个立方体,并给它一个位置,颜色,等等。虽然本书主要介绍 React VR,但它们之间有一些区别。 - -* React VR 应用是用**JSX**编写的。这是一种允许将类似 HTML 的标记混合到 JavaScript 代码中的语法。React VR 基于 React 和 React Native。如果您已经了解 React,那么您可以非常快速地学习 React VR,并且基本概念是相同的,因此您会感觉自己是本地人。 -* A-Frame 应用使用 HTML,带有自定义 HTML 标记。它是一个功能强大的框架,为 three.js 提供了一个声明性的、可组合的、可重用的实体组件结构。A-Frame 可以从 HTML 中使用,尽管开发人员仍然可以访问 JavaScript、DOM API、three.js、WebVR 和 WebGL。 -* 它们都允许定制 JavaScript 代码并直接与 three.js 和 WebGL 接口 - -但为什么要做出决定呢?你不必这么做。两者都可以使用。让我们安装 React VR。 - -# 安装 Node.js 和 React VR - -目前可用的大多数桌面虚拟现实硬件都使用 Windows;因此,下面的说明以及本书的大部分内容将混合使用 Windows 安装和 GearVR 查看。在撰写本文时,Linux 可能会被黑客攻击以与 HTC Vive 和 Oculus Rift 合作,但这是一条艰难的道路,有点超出了本书的范围。苹果电脑只是增加了为虚拟现实添加外部视频卡的功能,因为大多数电脑根本没有视频处理功能,无法以 Vive 和 Rift 耳机使用的分辨率呈现虚拟现实。 - -然而,React 不是特定于 PC 的。您可以使用 Linux 或 Mac 构建本书中的所有示例,并使用 Google Daydream、非官方硬纸板或三星 Gear VR 查看所有示例。在这种情况下,一些示例可能使用稍微不同的语法。我写这本书是为了让大多数拥有 Vive 和 Rift 的用户都能跟随我,我为平台限制提前向你们道歉。 - -为什么我们不能和睦相处? - -如果可能,我将包括其他平台的链接和信息。 - -# Node.js 的安装 - -我们假设您知道自己所在的平台,并且有一台能够安装 Node.js 和 React VR 的计算机(台式 PC)。 - -首先,我们需要安装 Node.js。如果您已经安装了它,那就太好了,只需确保(在编写本书时)您至少有 4.0 版。这本书是用 LTS 版本构建的:v6.11.0 和 v8.5.0,当你阅读这本书时,它应该是稳定的和过时的。(包括`npm`3.10.10) - -1. 您应该能够从:[获取 Node.jshttps://nodejs.org/en/download/](https://nodejs.org/en/download/) 。下载 64 位`.msi`预建安装程序文件。下载后,双击该文件或运行它,具体取决于您的浏览器。 - 我们生活在保姆状态,因此它会警告您正在下载本机代码。再说一次,人们总是被鱼叉式网络钓鱼(让你感染自己的病毒电子邮件)弄得不知所措。这个应该是安全的。 - -These warnings only come up if the program you are downloading has not been digitally signed. A digital code signing certificate is not that hard to get; insist that companies and non-profit organizations sign their code. - -It will make the internet safer.  - -Do this for any code you release. - -2. 单击下一步。 - -3. 我知道,但你真的应该阅读条款和条件——同意并点击下一步。 -4. 默认位置很好。谢天谢地,我们不必担心 Linux 不能处理文件名中的空格(只是跟你们 Linux 的人开玩笑)。 -5. 大多数安装选项都可以。它们不需要很大的空间,所以可以安装所有东西 - -6. 点击安装 - -7. 你可能(应该)得到一个警报,一个软件正在安装;告诉 Windows 没事 - -8. 你完成了!是时候安装 React VR 的其余部分了。点击 Finish。 - -Node.js for Mac: - -The Node.js organization recommends using Homebrew: [https://brew.sh/](https://brew.sh/). -But, you can also install Node.js via the Node.js download page:[ https://nodejs.org/en/download/](https://nodejs.org/en/download/). -Installation should be straightforward. - -Node.js for Linux: - -While the source code is here: [https://nodejs.org/en/download/,](https://nodejs.org/en/download/) you can download Node.js a little easier from the package manager; instructions are here: [https://nodejs.org/en/download/package-manager/.](https://nodejs.org/en/download/package-manager/) -Installation should be straightforward; this is Linux, so I'm sure you can handle any wrinkles. - -# Post Node.js 安装-安装 React VR - -Node.js 软件包括一个名为`npm`的包管理器。软件包管理器安装软件以及该软件的依赖项。您将使用此选项安装 React VR。它使安装非常容易,而且是最新的。无论您的平台是什么,您都需要使用命令提示符来处理本书中的大多数示例。命令提示符被错误地称为 DOS 的窗口。在 Windows 中,这被称为 Node.js**命令行界面**(**CLI**工具),尽管实际标题是 Node.js 命令提示符。Node.js 安装程序在安装时设置了此选项。您应该使用安装中的 CLI,因为它设置了某些环境变量等。说到这里,我使用了一个名为**Take command Console**(**TCC**的替代命令行工具,在 Node.js 完成安装并注册路径变量(安装的一部分)后,我可以从我的 TCC shell 运行`npm`和其他命令 - -安装步骤如下: - -1. 打开您首选的 CLI(启动`Node.js command prompt` -2. 键入命令: - -```jsx -npm install -g react-vr-cli -``` - -您可以从任何位置(文件夹)执行此操作,软件包管理器(`npm`命令)将安装以下内容: - -![](img/2910abed-bdb6-4459-85b8-b6f893a46b60.png) - -如果你再次运行这个程序,很好的一点是它会确认已经存在的内容(尽管和许多开源程序一样,它有点简洁)。 - -`npm` has a lot of other very useful options. For example, you can use `npm ls`, which will (just like `ls` in linux) give you a list of all objects installed. You can get exhaustive documentation by running the command `npm help npm`, which will open a web page. - -3. 然后,我们希望使用相同的 CLI 来安装`WelcomeToVR`示例。首先,转到要安装示例和代码的位置(文件夹/目录)。我有第二个大硬盘,安装为 F:(您的里程、平台和磁盘配置将有所不同)。因此,在开始安装桌面或文档上的所有内容之前,我切换到了数据驱动器: - -```jsx -f: -mkdir f: 2;reactVR -cd \reactVR -``` - -4. 然后,我继续使用 ReactCLI 安装`WelcomeToVR`演示: - -```jsx -f:\reactVR>react-vr init WelcomeToVR -``` - -该过程将开始: - -![](img/5bd76b93-cf1f-4645-a9f5-9e74d4354e5a.png) - -这需要很长时间。在流程结束时,它将完成并告诉您下一步要做什么: - -![](img/58701c79-4232-406e-b84b-d275cd7f19c6.png) - -5. 然后,进入工具刚刚创建的`WelcomeToVR`项目目录,初始化/启动本地开发服务器: - -```jsx -cd WelcomeToVR -npm start -``` - -这个过程需要一点时间。运行时,此命令行界面窗口将忙于运行程序。这不是一种服务。如果你关上窗户,它就会停下来。所以,不要关窗户。 - -当您访问各种网页时,此窗口还将显示有用的状态信息: - -![](img/bd8152ae-3e63-4196-bcbc-c8f4e1d5cc7d.png) - -6. 然后,从您的桌面,打开您的浏览器到`http://localhost:8081/vr/index.html`[、](http://localhost:8081/vr/index.html),就像 nice CLI 告诉您的那样。你完了! - -与打开 web 浏览器并在 CLI 窗口中键入 URL 相比,找到此 URL 的方法更简单。您应该打开*快速编辑模式*。显示这一点的屏幕截图如下: - -1. Click on the little C:\ window in the corner of the CLI window. This is called the System Menu: - - ![](img/d4498878-271b-45a3-972d-cb33295f7702.png) - -2. Once you've done that, click on Properties. Once in Properties, turn on Quick Edit Mode: - - ![](img/ea17d3cc-6e93-4f9e-bcee-f5b0862ca284.png) - -3. Click OK. Now, that Quick Edit Mode is turned on, you can highlight text in the window and press *Enter* to select. Then, you can paste the URL directly into your WebVR-enabled browser. Easy! - - ![](img/d4d2c65f-0a44-4c65-9a53-c3f6b0cee22c.png) - -4. 如果像我一样,您喜欢使用与 COMMAND.COM 不同的 CLI(我使用 4nt 或 TCC),假设我们讨论过的是默认安装,那么您只需在`path`中添加以下内容,假设您在默认位置安装了 Node.js:`C:\Users\\AppData\Roaming\npm;C:\Program Files\nodejs`。 - -# 安装 WebVR 浏览器 - -现在已经安装了服务器端软件,您需要安装能够显示 WebGL、OpenGL 和 WebVR 的 web 浏览器。这种情况会不断变化,所以我强烈建议您去 WebVR 查看他们的兼容性列表。 - -Firefox 或实验性的 Firefox Nightly 可能是最容易使用的浏览器。更多信息,请参考[http://bit.ly/WebVRInfo.](http://bit.ly/WebVRInfo) - -好消息是,从版本 55 开始,Firefox 内置了常规的 WebVR 支持,所以只需确保您是 Firefox 的最新版本,就可以查看 WebVR。要查看刚刚生成的新 VR 站点,您需要执行以下步骤: - -1. 确保您的浏览器可以运行 JavaScript。这是默认设置,除非您已以安全意识的方式锁定浏览器(这是一件好事)。WebVR 广泛使用 JavaScript。您还可以将 localhost 添加到白名单中。 -2. 一旦打开支持 WebVR 的浏览器(在 PC 上,基本上是 Chromium、Firefox Nightly 或 IE),您将看到 hello。然而,你还没有进入虚拟现实!您需要在 VR 中单击视图。你的 VR 应用应该启动了。然后你可以戴上耳机,你会看到一个简单的问候。没有世界?你在虚拟现实世界里! - -![](img/df2cf0ca-561d-40f1-961e-27ba268ef751.png) - -3. 你会看到一个链接,上面写着“在虚拟现实中查看”。除非你点击这个(在你的普通桌面上),否则你的 Vive/Oculus 将无法工作。 -4. 一旦你在虚拟现实中点击 View,戴上耳机,你就会在虚拟现实世界中看到 hello! - -![](img/5a2673ec-4726-46d5-9bbe-cac69c71b738.png) - -祝贺你已经建立了你的第一个虚拟现实世界。我打赌这比学习统一要快得多。 - -您也可以使用移动 VR 进行查看,但您需要找到开发机器的 IP 地址,并从移动设备访问该网站,例如:`http://192.168.1.100/vr`。 - -在 URL`http://localhost:8081/vr/index.html`中,用服务器(桌面开发 PC)的 IP 地址替换`localhost`。 - -请注意,这几乎肯定不是正确的 IP 地址,您需要从开发机器/服务器获取 IP 地址,并将其输入移动 VR 耳机。对于 Windows,请转到网络属性,或从 CLI 类型`ipconfig`转到网络属性。如果您的台式电脑为 192.168.0.100,则您可以通过移动耳机(从虚拟键盘)键入`http://192.168.0.100:8081/vr/index.html`。 - -Clean your mobile screen - -If you don't, any specs, fingerprints, or crud on the screen will show up sharply in focus. These specs will be distracting as they will seem to hover in front of everything. - -祝贺您已经跑过并观看了您的第一个虚拟现实世界! - -现在,如果你没有耳机,或者有点厌倦了打开和关闭耳机,不得不走出房间范围回到你的电脑,有一种快速预览世界的方法。在 Firefox 每晚,你只需点击小雷达显示屏,屏幕上的内容就会显示一只眼睛在 VR 耳机中看到的内容。这对像您这样的开发人员非常有用! - -![](img/63f8fd9c-a988-42cc-a170-e12f11f2624b.png) - -# 总结 - -在本章中,我们介绍了如何实际编程虚拟现实世界的基础知识,以及使用什么软件。我们还安装了 React VR 系统,以便开发我们自己的虚拟现实世界!在下一章中,我们将介绍构建虚拟现实世界所需的 3D 基础知识和数学知识。 - -不要担心数学问题;不会有弹出式测验。 - -现在来创造一个有趣的世界。但首先,我们需要了解世界是由什么组成的。下一节将介绍 React VR 术语来描述您的虚拟世界。 \ No newline at end of file diff --git a/docs/get-start-rvr/03.md b/docs/get-start-rvr/03.md deleted file mode 100644 index 8299e79..0000000 --- a/docs/get-start-rvr/03.md +++ /dev/null @@ -1,351 +0,0 @@ -# 三、除 X 和 Y 以外的三维或真实维度 - -我们决定踏入一个真实的世界。要理解如何画那个世界,我们需要准确地理解如何描述它。 - -本章描述了我们如何在数学意义上做到这一点。别担心,这不是高中数学的回归!(好吧,也许是几何。好吧,也许有一点。好吧,也许很多。我会尽量让它无痛。) - -有许多不同的方式来描述世界;不管我们怎么做,世界还是一样。正如莎士比亚在《罗密欧与朱丽叶》中所说: - -"What's in a name? That which we call a rose by any other name would smell as sweet." - -有趣的是,在我们的例子中,情况并非如此:一个描述错误的盒子看起来会完全不同。你需要学习这门语言。不仅如此,您还需要了解 React VR 如何描述世界,因为不同的 3D 图形程序都使用不同的数字(缩放)、方向(向量)和旋转。 - -对于虚拟世界,软件和硬件的类型都需要不同的方式来描述所看到的事物。例如,坐标可以是左手坐标或右手坐标。如果您将它们混合在一起,对象将以不同于您预期的方向移动! - -特别是,up 在 3D 中有不同的含义;更具体地说,向上方向通常不是不同 3D 程序之间的标准方向。在 React VR 中,Y 向上。为什么 Y 向上?请继续阅读以了解: - -* 坐标:这些是空间中的固定点 -* 点:这些是多边形的构建块 -* 向量:这些是方向 -* 变换:这些是将东西移动到你想要的地方 -* 渲染:这将把对点和变换的讨论变成了真实的东西 - -# 超越平地-3D 概念 - -为了用 3D 表示事物,我们必须将我们看到的东西转换成计算机可以用来生成图像的东西。这些方法将涉及具有 3D 几何图形、图片和代码的文件。首先,让我们讨论一下如何在 3D 中定位物体。 - -要在 3D 中表示对象,我们需要它们的位置。Excel 之类的电子表格使用 A-Z(交叉)和 1-66 向下(实际上是 A-XFD 和 1-1048576)。计算机图形学对所有三个轴都使用数字。但是,有不同的方法来编码这些坐标。 - -这适用于刻度(什么是 1,1 英寸?1 英里?)和它们的方向(向上是 Y 还是 Z?)。为了解决这个问题,我们需要讨论坐标系。 - -# 协调 - -我们都习惯于用*X*和*Y*网格绘制纸张、网格和发光的电子表格,或者在您使用的任何电子表格程序中使用 A1 和 B1 等数字和字母。进入第三维度可能会让人困惑,即使那是我们生活的地方。这就是为什么我把这部分叫做*超越平原*。 - -我们认为在二维或一般数学中理所当然的数学运算,结果只是在三维中有所不同。例如,如果将*X*和*Y*相乘,得到的答案与将*Y*和*X*相乘得到的答案相同。然而在三维空间中,旋转并不是这样的。若要看到这一点,请尝试同时复制这本书。(我买了两本,是吗?妈妈,你在看吗?) - -好的,说真的,请随便拿两本书,真正的纸质书。如果你有两台 Kindle,你可以用它们。 - -1. 第一本书: - 1. 从左向右(朝向右手)翻转四分之一圈(关闭时)。 - 2. 然后,将后缘朝您翻转(翻转)。 - 3. 您现在可以从侧面查看后面的页面。 -2. 对于第二本书,按相反顺序翻转: - 1. 将后缘朝你的方向翻转(翻转)。 - 2. 然后顺时针从左向右(朝向右手)旋转四分之一圈。 - -这两本书面向不同的方向,尽管两次旋转的方式相同,只是顺序略有不同。 - -三维数学可能令人困惑。通常,如果将*A*和*B*相乘,得到的结果与将*B*乘以*A*得到的结果相同。 - -当涉及到平移、旋转和缩放时,这个概念非常重要。对象在世界中的最终位置以及它们的外观取决于编码顺序。 - -我们将使用三个数字为 3D 中的所有内容指定一个位置,具体为*X*、*Y*和*Z*。 - -This is called a **Cartesian coordinate system**. There are other types of coordinate systems, but nearly every computer system uses a Cartesian system for spatial locations. Rotations and vectors will sometimes use other systems. This is a **Euclidean space.** - -为了让 3D 更加混乱,一些人使用了*X*和*Y*,其中*Z*是新的维度,而另一些人说*Y*向上。为什么会这样?在处理屏幕时,您习惯于使用*X*和*Y*。一张纸是类似的,尽管纸通常是水平的,屏幕是垂直的。 - -这导致了一个有趣的转换为 3D 的问题。在 3D 中,我们使用了*X*、*Y*和*Z*。如果你习惯于使用*X*和*Y*,那么*Z*必须是新的三维空间,并且它将向上。然而,如果你习惯于将*X*和*Y*视为一张图表,那么*Y*已经向上,因此*Z*最终会进进出出。每个 3D 系统似乎略有不同。 - -WebGL, which React VR is based on, uses the familiar *X* and *Y* as right/left and up/down; so *Z* has to be in/out. However, one difference is that in React VR, *Y* is up; in standard HTML, *Y* is down. In other words, HTML and React use a coordinate of (zero by zero) as the upper left-hand corner. Is *Y* is up? Most 3D programs use *Y* or *Z* as up, meaning in our case positive *Y* is up. - -WebGL 和 HTML 与 React 不同,可能需要一些时间才能习惯。要把一个物体向前推,这样你就能看到它,你需要给它一个负 Z。 - -在 3D 中,坐标可以是左手坐标或右手坐标。正如我们在*X*、*Y*和*Z*中所看到的,有时箭头不会指向您期望的方向。为什么 VR(实际上是 OpenGL)没有决定让 Z 进入屏幕?那么坐标将是左手的。相反,大多数图形系统使用右手坐标系 - -My brother is left handed. -          Nothing wrong with a south paw. -          (Actually, he's right handed, but why spoil a good story with the facts?) - -右手和左手到底意味着什么?它是一种帮助记忆箭头方向和旋转的助记符。如果你握住任意一只手,将前三个数字展开,它们会拼出*X*、*Y*和*Z*方向。一张图表会有帮助;您的前三个手指(拇指、食指、中指)指向正*X*、*Y*和*Z*: - -![](img/70da68aa-7788-45c8-a787-59a2251ef419.jpg) - -在这个图表中,有几件事需要注意。相机在左边代表我们,看着屏幕(透明地描绘)。因为*Y*向上(为什么?),而*X*在右边,OpenGL 使用的坐标系与 HTML 或一张图纸不同,但它或多或少是 web 的标准。 - -选择此选项是为了更容易地映射到 3D 模型、**计算机辅助设计**(**CAD**)和建模程序(如 Blender、Maya、3DSMax)的构建方式。这与 React 的工作方式相反-*Y*与 React 一起下降为正值。这是一个惯用右手的系统;如果你试着用左手来做这件事,你会得到不同顺序的*X*、*Y*和*Z*轴 - -# 旋转呢? - -使用 React VR 和 OpenGL 绕任意轴旋转也是右手操作。这意味着围绕任何轴的正旋转将沿着拇指指向和手指弯曲的方向进行。例如: - -![](img/7d08ad11-3632-48f3-bd08-47cfc805800f.jpg) - -你是不是看着你的右手卷曲手指?没关系,这有助于形象化。是的,这些是箭头,显示了沿各自轴的正方向。 - -Honestly, *Y* is up and *Z* is up seem to be pretty commonly mixed in the 3D CAD world. Your CAD system may work differently. It's OK, we can flip and invert it - just be aware that when importing models, you may find them on their side, or even inside out.  -In particular, in Blender *Z* is up and *X* and *Y* are in the flat plane; however, it can substitute *Y* for up when you export. Why? Because it's on the up and up. - -这些数字是无量纲的;一个 1×1×1 的立方体可以被认为是 1 英里或 1 英尺。然而,在 WebVR 和 React VR 中,单位通常以米为单位。 - -Blender can use dimensionless units, metric, or imperial, so you'll need to fiddle with scaling when importing things. -The program Poser uses odd units—you will need to scale anything coming from it. -OBJ files, commonly used for importing models, have no unit information; they are dimensionless: 1 is 1, not 1 meter. - -# 要点 - -点指空间中的 3D 位置,通常通过*X*、*Y*和*Z*位置识别。除非进行本机渲染,否则在 React VR 中很少直接描述点,但空间中的位置通常被描述为点。例如,变换节点可能会说: - -```jsx - transform: [{ - translate: [0, 400, 700] - }] -``` - -应用变换的对象的中心将位于位置*X*=0、*Y*=400、*Z*=700。 - -# 载体 - -向量指的是一个方向。在航空业,飞行员谈论矢量。在电影*飞机*的场景中,克拉伦斯·奥维尔、罗杰·默多克、维克多·巴斯塔和控制塔讨论航向:([http://bit.ly/WhatsOurVector](http://bit.ly/WhatsOurVector) - -"Roger Murdock: Flight 2-0-9'er, you are cleared for take-off. -Captain Oveur: Roger! -Roger Murdock: Huh? -Tower voice: L.A. departure frequency, 123 point 9'er. -Captain Oveur: Roger! -Roger Murdock: Huh? -Victor Basta: Request vector, over. -Captain Oveur: What? -Tower voice: Flight 2-0-9'er cleared for vector 324. -Roger Murdock: We have clearance, Clarence. -Captain Oveur: Roger, Roger. What's our vector, Victor? -Tower voice: Tower's radio clearance, over! -Captain Oveur: That's Clarence Oveur. Over." - -从我们 VR 人的角度来看,他们真正的意思是前进。在三维空间中,也可以向上或向下瞄准。这三个方向对我们都很重要 - -正确地说,翻译使用向量;如果将变换属性赋予[0,2,0]的对象,则告诉该对象在*+Y*方向上移动*2 个*单位,而不必移动到绝对位置 0,2,0。但是,请注意,如果对象的原点位于 0,0,0,则它是相同的。重要的是要考虑你的 3D 对象在翻译它们时的原点,以及对象是否采取绝对或相对定位。 - -# 转变 - -这不是一本关于奇怪的可折叠机器人的书,所以我们谈论的是变换,而不是变形金刚 - -变换是放置、定位、移动和缩放对象的方法,本质上是变换对象、点的*X*、*Y*、*Z*坐标等的任何东西 - -在 React VR 中,变换通常是样式的一部分。例如: - -```jsx -style={{ - transform: [ - {rotateZ : this.state.rotation}, - {translate: [0, 2, 0]}, - {scale : 0.01 }, - ], - } -``` - -变换顺序非常重要。正如我们前面所讨论的,在 3D 中,如果你先平移,然后旋转,那么变换是不可传递的。如果你先旋转,然后再平移,那么变换最终会出现在不同的位置。还记得书中的例子吗? - -在 React VR 中,变换是大多数具有物理存在的对象的标准道具节点。(见附录及[第 4 章](03.html)、*反应 VR 库*) - -转换实际上有三个主要参数(和许多不推荐的道具);转换或矩阵参数。 - -是的,我说的是矩阵。 - -![](img/f12b10ac-92c1-40c0-9af8-a8e99912e12d.png) - -一段时间以来,矩阵一直是一个数学概念。它也拍了一部很棒的电影。由于版权限制,我不能在这里包含矩阵的图片,但上面是我在矩阵中查看的 VR 控制器场景的表示。无论如何,我不是指电影。我们将使用矩阵创建我们自己的 3D 场景。 - -矩阵是描述平移(向量)、旋转、缩放和倾斜的数学方法。我的一些朋友在周末会发生歪斜,但歪斜是一个数学术语,意思是移动,比如说,物体的顶部超过底部。你可能会认为它是倾斜的。 - -为了充分理解矩阵,让我们谈谈非基努·里夫斯的方法。 - -任何时候有一个物理对象,比如一个盒子、一个模型、一盏灯或一个 VR 按钮,你都有各种各样的风格道具,其中之一就是变换。变换节点可以使用矩阵,或者有时更简单的直接转换属性。例如,如果您在 React VR 中定义了一个`Cylinder`,您可能会将其转换为: - -```jsx - -``` - -转换顺序很重要。以下是三个圆柱体的示例,除了颜色和变换外,它们是相同的: - -```jsx - - - - -``` - -这就是虚拟现实世界的结果: - -![](img/a28d4478-ef65-41b7-82b7-8f6a0d669186.jpg) - -# 矩阵呢? - -矩阵是一个四列乘四行的数列(数组)。 - -您也可以在变换节点中使用`matrix`。关于矩阵数学的完整讨论超出了本书的范围。网上有很多参考资料。基本概念相当简单,但旋转编程可能有点困难(尽管是确定性的)。 - -翻译存储为: - -```jsx -[1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - Tx,Ty,Tz,1]. -``` - -缩放由以下内容表示: - -```jsx -[Sx,0, 0, 0, - 0, Sy,0, 0, - 0, 0, Sz,0, - 0, 0, 0, 1]. -``` - -旋转可以用以下公式中的 R 值表示: - -```jsx -[R00,R01,R02,0, - R10,R11,R12,0, - R20,R21,R22,0, - 0, 0, 0, 1]. -``` - -Rotations via matrix math are very precise, but also very complicated. As we saw earlier, the order of rotations will change the location of the resulting object. A matrix does not have these problems as the order is baked into the matrix itself. Calculating the rotations can be messy. -Generally speaking, you'll want to use the transform styles instead of the matrix math, when moving an object by hand (manual coding). - -You'll want to use matrices when copying an object's position and orientation or programmatically moving it. - -将所有轴缩放十分之一并平移[3,2,1]的`matrix`可以作为`matrix`应用,如下所示: - -```jsx -style={{ - transform: [ - {matrix : [0.1,0,0,0, 0,0.1,0,0, 0,0,0.1,0, 3,2,1,1]}, - ], - }} -``` - -不能同时使用变换矩阵和变换样式(单独平移、旋转、缩放)。实际上没有必要这样做,因为您可以通过指定单个变换来使用矩阵完成所有操作。如果你真的使用矩阵,那你就是铁杆!在任何情况下,您创建的任何变换都将在幕后转换为矩阵。 - -欢迎来到矩阵-现在您可以创建它了。 - -# 翻译 - -Bing 将渲染定义为: - -![](img/862749ff-cd38-427a-9b98-edee837df825.png) - -嗯,这很有趣,但显然不是我们的意思。渲染是指将我们讨论过的所有数学描述都提取出来,并将其可视化。 - -React VR 使用的渲染引擎是 three.js([http://bit.ly/2wHI8S9](http://bit.ly/2wHI8S9) ),通常使用 WebGL 进行渲染([http://bit.ly/2wKoKCe](http://bit.ly/2wKoKCe) )。WebGL 是用于生成高性能图形的健壮 JavaScript API。它将在您的系统中使用任何高性能图形硬件(GPU),并且在大多数浏览器中都是本机的,允许在不需要插件的情况下使用 3D 图形 - -然而,通常情况下,网络可能是一个疯狂的地方。你可能认为浏览器制造商会从 20 年前的兼容性问题中学到教训,但遗憾的是,事实并非如此。某些浏览器可能会出现皱纹或问题,尤其是在移动设备上。 - -我们就不能和睦相处吗? - -这将影响 React VR 的工作效果。如果您想查看您的浏览器和硬件是否支持 WebGL,请转到位于[的 WebGL 测试页面 http://bit.ly/WebGLTestPage](http://bit.ly/WebGLTestPage) 。页面将显示旋转立方体;如果您使用的浏览器不是百分之百兼容的,它也可能会显示一些警告。WebGL 维基百科页面([http://bit.ly/2wKoKCe](http://bit.ly/2wKoKCe) 很好地描述了哪些浏览器做了什么,但情况发生了变化。 - -你需要测试一下。 - -# 测试它的外观 - -在第二次世界大战中,海军陆战队在太平洋战前对两栖登陆进行了广泛的试验。他们制定了战术和战略。 - -他们第一次登陆时就撞上了珊瑚礁。圣地亚哥/彭德尔顿营地没有珊瑚礁。结果,虽然这是一次成功的着陆,但灾难性比他们想象的要大。因此,海军陆战队有一句谚语: - -"Train as you would fight." - -这显然是一个由来已久的说法。罗马军团曾经说过: - -"Training should be a bloodless battle, so that in battle, it will be just like bloody training."  -Roman Legion Training Maxim - -如果你习惯使用 Firefox,将你的 React VR 解决方案发送到世界各地,而你的佩妮阿姨则使用管弦乐队浏览器查看你的世界,这可能不起作用,佩妮阿姨仍会认为你是她漫无目的的兄弟姐妹的无能后代。请注意,据我所知,虽然我使用 Opera,但 Orchestra 并不是真正的浏览器。 - -如果这是一个仅限公司内部使用的应用,并且您只有一个浏览器的标准(并且可以强制执行!),那么您可以使用该浏览器进行测试和开发。 - -然而,如果你想让很多人使用你的 React VR 应用,你真的应该用各种浏览器进行测试。如果你有一台 Mac、一台 PC 和一台 Linux 机器,那就更好了。这些可以是虚拟机(特别是具有适当许可证的 Windows 和 Linux),否则您需要测试人员。 - -你需要测试它,就像你期望人们使用它一样,否则你会认为它工作得很好,但人们不会对你的虚拟世界感到满意。您需要使用尽可能多的浏览器和硬件平台进行测试。当然,这并不实际,但却是必要的。这就是 beta 测试人员可以派上用场的地方。 - -# 渲染工作原理 - -我们在讨论渲染。这是一个获取数学模型并表达属性的过程,然后将其转换为您可以在屏幕上看到的内容。 - -要在 VR 中渲染模型,React VR 使用基于 OpenGL 的 WebGL。WebGL 是一种 JavaScript 实现,如果大多数平台在浏览器中具有相当功能的实现,那么 WebGL 在大多数平台上的外观应该是相同的。如果你描述一个直立的红色箭头,那么在所有浏览器中它看起来都像一个指向上的箭头。关于测试的建议通常是确保它能工作,而不是确保向上箭头不会突然指向右侧。 - -在游戏行业,人们通常不得不在 DirectX 和 OpenGL 之间做出决定。尽管 WebGL 基于 OpenGL 的设计,但 WebVR 两者都不使用。与 OpenGL 不同,它还包括 HTML 元素,如 HTML5 画布和 DOM(文档对象模型)。如果您使用的是 WebVR,那么您就是在使用 WebGL。 - -If a browser you are testing doesn't show your scene right, but does in other browsers, *please* file a bug report. Many of these applications are, like most cool things on the internet, bleeding edge.  - -You want these bugs fixed — so tell people. Programmers can't fix what they don't know about. You'll be contributing to a saner, cleaner, more effective web. - -Be part of the solution! - -WebGL 通常通过 OpenGL 或 DirectX 使用高性能 GPU。您不需要担心,也无法控制 React VR 使用的是什么(除非使用本机),尽管这是一个优势。web 浏览器通常会做正确的事情,并使用手机(手机、平板电脑)、笔记本电脑或台式机上的任何可用硬件。 - -如果要更精确地控制渲染,该怎么办?这在 React VR Native 中是可能的,我们将在后面的章节中详细介绍。 - -# 总结 - -在本章中,我们学习了描述虚拟现实世界的重要方法。如果你想这样想,我们正在学习矩阵的语言,它甚至涉及到矩阵。我们学习了 3D 坐标、点、向量、右手和左手世界以及变换 - -我们了解了所有这些概念是如何组合在一起并呈现的,以及不同的网页如何获取这些信息并从中创建视觉空间。我们也学会了测试! - -为了能够进行测试,我们需要浏览器中的一些东西。要做到这一点,我们不仅需要知道如何用数字和旋转来描述世界,还需要知道这些数字是如何用于构建模块的。下一节将介绍 VR 用来描述世界的关键字、组件和对象。 \ No newline at end of file diff --git a/docs/get-start-rvr/04.md b/docs/get-start-rvr/04.md deleted file mode 100644 index 9bb33e5..0000000 --- a/docs/get-start-rvr/04.md +++ /dev/null @@ -1,596 +0,0 @@ -# 四、React 虚拟现实库 - -本章是关于 React VR 库的布局;其中的对象和组件。本章中的许多概念将在后面的章节中引用,因此,如果您正在阅读本书的电子版,它将与您的享受和乐趣紧密链接。 - -React-VR 包含六个基本元素,并使用一个新的但熟悉的范式 JSX(JavaScript 扩展)进行编码。如果您已经知道 React,那么您已经熟悉 React VR,尽管有一些重要的区别。我们将介绍以下内容: - -* JSX,React VR 的语言和语法: - * React 与 React 的区别 -* 组件和虚拟现实组件: - * 道具 - * 状态 - * 事件 - * 布局 - * 风格 -* 所有组件和关键字的详细信息: - - * 可见和不可见的对象 - * 灯 - * 多媒体-声音和视频 - * 摄像机和观察 - -我还没有涵盖所有可用的 API,因为这大部分是一个冗长的、类似词典的背诵,如果您使用网站上的文档来探索 API,那会更好。在后面的章节中,我们将使用关键 API 为我们的世界添加生命并在其中导航。有关完整、最新的 API 列表,请查看文档([https://facebook.github.io/react-vr/docs/getting-started.html](https://facebook.github.io/react-vr/docs/getting-started.html) )。 - -# JSX—React VR 的语法 - -React-VR 看起来对 HTML 很熟悉;这使其易于阅读、编辑和部署。在幕后,React 和 React VR 使用的 UI 语法胶水将被编译成 JSX 或 JavaScript 扩展。JSX 是一个 React 语法扩展,允许混合使用 HTML 和 JavaScript 进行编码。您还可以直接编写 JSX 代码。 - -React JSX 的示例如下所示: - -```jsx -const element =

My title!

; - -``` - -这不是字符串,因为它不在引号中,也不是 JavaScript。与直接用 JavaScript 编写代码相比,它可读性更强,使用更方便。JSX 使编程更快,更具声明性。 - -It is useful, but all of this readability and easy-to-program nature comes with a few pitfalls. One of them is that semicolons will automatically get entered. Just like with HTML, you can include extra lines, but your code may get extra semicolons that you didn't intend.  - -Put parentheses around your code to avoid this--I also highly recommend reading up on the JavaScript syntax. A few of the things in this book took me longer than they should have, as I'm a C++ programmer, not a native JavaScript programmer.  - -编译 React VR 后,JSX 会自动转换为 JavaScript。这意味着您可以在任何使用 JavaScript 的地方包含 JSX - -# React 与 React 的区别 - -在 React 中,您的大部分想法都围绕着自 JavaScript 开始以来就让我们既着迷又愤怒的**文档对象模型**(**DOM**)。使用 React VR,您需要忘记 DOM;这样,React VR 与 React Native 更相似。即使这样,也有一些概念需要忘记。 - -忘掉像素作为一个维度;这个概念对于虚拟现实来说毫无意义。 - -你可以把一张巨大的照片放在世界上很多地方,看起来像颗粒状的,而一张小照片放在一个小的物理物体的侧面,看起来非常锐利。你可以离物体更近或更远(假设你已经编程了移动),这将极大地改变物体的“像素”宽度。取而代之的是,一切都是以米为单位的(如果你来自一个坚持使用过时的“英尺”单位的国家,你可以假装这些单位是以码为单位的。足够接近虚拟现实工作)。 - -另一个在 React VR 中似乎有点奇怪的概念是渲染速度**。**使用 React,您的页面被加载,然后显示,然后页面的元素可以交互(点击),但除非有人点击*刷新*,否则整个页面很少被重新呈现。属性改变时调用对象的`render`方法。这并不意味着你必须有一个计时器来“挠痒痒”在虚拟现实中呈现你的页面。 - -随着反应 VR,整个页面以较少的(希望)超过 16 毫秒呈现,使得每秒 60 帧,现在认为是 VR 必不可少的。不重新分析整个页面。这在某种程度上与常规 HTML 相反。特别是,对于活动网页,单个 VR 组件将以每秒 60 帧的速度呈现(显示),当其属性更改时,将呈现(到 three.js 代码)以更新该表示。 - -呈现对象与页面呈现不同。这可能有点令人困惑。您的页面将在开始加载时立即呈现,即使各种对象的`render()`方法(将它们转换为 three.js 代码)尚未运行。 - -最终的结果是,在没有任何额外编程的情况下,当世界中的属性更新时,对象将根据这些属性的更改而显示。这是 React 工作原理的基石,同样适用于 React VR。这将添加每秒多个帧速率的渲染 - -既然我们已经介绍了 React VR 不是什么,那么让我们来介绍 React VR 是什么。 - -# 核心部件 - -React VR 具有可重用的 UI 元素,您可以在各种地方使用。这些被称为**组件**。有两个内置组件: - -* 文本 -* 形象 - -您还可以通过扩展`React.Component`来构建自己的组件。 - -组件是真实的东西,而不仅仅是标签或占位符,因为它们是通过`render()`函数在世界上展示自己的方式构建的。这不仅仅是一个功能;与 React VR 的所有内容一样,`render()`通常有一组呈现或描述其内容的子组件。组件的示例如下所示: - -```jsx - -``` - -这将是一个文本组件,一个内置类型。 - -# 虚拟现实组件 - -VR 对象,即您通常认为的组件,将在后面介绍。React VR 文档在核心组件一节中没有提到它们,这有点令人困惑。您可能在想*“只有文本和图像?对象呢?”*VR 组件是我对以下内容的术语: - -* **VR 物理组件**:这些是你在世界上可以“看到”的对象: - * 三维基本体,包括长方体、圆柱体、平面、球体和导入的对象(可以非常详细) - * UI 元素,如面板和按钮 -* **灯**:这些灯照亮前面的物体,可以有几种类型。请注意,在 React VR 中,当前照明不会为实时速度投射阴影。 -* **多媒体**:包括视频和声音。通过这种方式,您既可以为 360 视频创建移动背景,也可以在您正在创建的世界中拥有“电视”。 -* **摄影机和场景**:摄影机控制渲染,场景包含您放置在其中的所有对象。 - -我们将在后面的*下一级*小节中介绍这些关键词 - -# 道具 - -如果组件没有属性,它们会很无聊。我们向您致意的物业示例如下: - -```jsx - -``` - -`name`和其他类似的值被称为**道具**。道具是 name,有一个值,我将其设置为一个幽默的字符串。可以通过编程方式访问它们,例如,`{this.props.name}`。 - -许多三维对象也具有属性;这些因对象而异。 - -# 状态 - -也许我们正处于混乱状态,但 React VR 状态非常重要,因为它会影响所有组件的显示,从而影响这些组件的各种道具。如果组件的道具(外部)或状态(内部)更改,组件将重新渲染自身。 - -Rendering does not necessarily refer to "creating an image for the eyeball", although it can. Rendering, in this case, can refer to building code through the React VR/JSX compilation process.  - -React VR 按照面向对象原理/编码范式进行封装,因此可修改状态位于组件内的`this.state`对象内。只能通过“设置”功能进行修改,具体如下:`this.setState({myStateVariableBeers: 99 })` - -注意,虽然一开始这看起来像是在扩展 HTML/JSX 格式,但这正是 React VR 如此强大和简单的原因。 - -# 事件 - -**活动**不仅仅是在你的邻居家里玩的有趣的事情,它们也是让你的虚拟现实世界变得活灵活现的方式。当用户通过**用户界面**(**UI**进行某些操作时,会生成事件。当您将光标移入和移出视图区域时,`View`组件发送`onEnter`和`onExit`事件。 - -精明的读者应该感到困惑——我们正在谈论虚拟现实,我刚才提到了这个领域。为什么 2D 概念作为 3D 语言的基本组成部分被讨论? - -事件和布局(下面将介绍)遵循 2D 范式,是您习惯于使用的东西(HTML、CSS 和 JavaScript,以及虚拟现实世界)之间的一个简单桥梁示例。然而,两者之间存在着差异,而且在任何道具和关键词中都没有考虑“像素”的说法可能从根本上看起来很奇怪。这是因为在真实的 3D 世界中,使用像素作为测量单位的想法基本上是无用的。在你前面一米的物体比在你后面十米的物体有更宽的屏幕。因此,尺寸以世界空间为单位;一个是一米(比一码多一点)。 - -React-VR 的目的是快速而明确地构建伟大的 3D 世界。它是一种声明式编程方法。如果您想构建更复杂的世界,React VR 的强大之处在于您可以使用 React Native 和其他 Node.js 编程方法添加到 React VR 中。 - -# 布局与风格 - -WebVR 和 React VR 的各个方面仍然遵循浏览器范式。光标被视为二维交互,UI 元素通常根据二维弹性框和布局规则进行描述,以便在二维中布局这些组件。这并不意味着我们没有开发虚拟现实环境;尽管大多数 UI 是 2D 格式的,但它们在 VR 环境中完全存在 - -布局和样式自然地进入 3D。您可以设置类似于样式表或 CSS 的内容,而不必描述每个项目的 3D 对象(内联)。它实际上与样式表不同,它是一个样式表,所以你的所有技能都会转移。 - -样式表可能会很凌乱,因此 React VR 使布局 UI 元素更加容易。它使用 Flexbox,通过 YogaLayout(在[处)https://github.com/facebook/yoga](https://github.com/facebook/yoga) )。React 虚拟现实就是快速创造现实。React 是关于用户界面的,所以 React VR 中的 UI 元素功能强大是很自然的。 - -# 下一个层面——细节 - -尽管 React VR 库很简单,但要真正了解它的全部内容,您需要学习很多语法。你可以略读一下,但在不熟悉任何东西的情况下了解一点是有危险的。 - -"A little Learning is a dang'rous Thing; -Drink deep, or taste not the Pierian Spring: -There shallow Draughts intoxicate the Brain, -And drinking largely sobers us again." -An Essay on Criticism, by Alexander Pope. - -你可能会想*“好吧,但是虚拟现实的东西都在哪里?你知道,桌子、椅子、灯、人……**等等。”*这些确实是一种深度饮料——有很多成分。 - -最好的参考是在线文档,尽管它们有时有点稀疏。请记住,在线文档是*live*,这意味着您可以提交一个问题,甚至修改它,如果您看到输入错误或需要澄清的话。 - -我强烈建议您将下一节视为参考节。当然,你可能需要帮助才能在晚上入睡,在这种情况下,请继续阅读!说到这里,本节非常重要,因为您需要使用许多或所有这些组件来实际构建您的虚拟现实世界。我将尝试使这一部分有趣。我在写一本书,而不是试图在舞台上谋生,这是一件好事。 - -# 填充(对象,无论是否可见) - -世界上大多数有趣的东西都是可见的或可以与之交互的对象。大致上,按照复杂性的顺序,如下所示: - -* 盒 -* 圆柱 -* 飞机 -* 球 -* 圆柱状耳 -* 模型 -* 帕诺 -* 视频控制 -* VrButton - -# 原语 - -长方体、圆柱体、平面和球体是三维基本体。他们有`lit`、`texture`和`wireframe`道具。照明对象将受场景中灯光的影响。如果指定了纹理(通常是图像文件),浏览器将查找(获取或渲染)该图像,并使用它环绕 3D 基本体。我们将在[第 6 章](06.html)、*与 Poly 和 Gon 家族合作*和[第 7 章](07.html)、*与(虚拟)茶壶坐在一起*中讨论 UV 映射,但大多数 3D 原语的映射方式与您期望的方式相同。 - -注意,纹理可以是`string`(指图像文件)、`asset()`调用或`require()`。 - -# 盒 - -`Box`是一个基本立方体。如果未指定,其尺寸将默认为一(单位)。 - -```jsx - -``` - -这将是 2001 年太空漫游中的巨石;尺寸是前三个素数的平方。更多信息,请参见[https://facebook.github.io/react-vr/docs/box.html](https://facebook.github.io/react-vr/docs/box.html) 。 - -# 圆柱 - -`Cylinder`是一个基本的有盖圆筒。它还可以通过使顶部半径为零(或关闭漏斗的底部半径)来制作圆锥体。 - -The `Cylinder` uses radius, not diameter. Don't make your cylinders twice as large as they need to be! - -```jsx -// Round cylinder -//Doric order column - - -// Great Pyramid - -``` - -注意创造性地使用了边数,使圆锥体成为金字塔。有关更多信息,请参见[https://facebook.github.io/react-vr/docs/cylinder.html](https://facebook.github.io/react-vr/docs/cylinder.html) 。 - -与所有 3D 原语一样,`Cylinder`有`lit`、`texture`和`wireframe`道具 - -# 飞机 - -这不是空中客车,而是一个平面。虽然它被称为一个**平面**,但它更像一个平面、正方形的 2D 平板。它不是立方板,这将是一个`Box`: - -```jsx -//concrete slab using industry norms for size - -``` - -飞机有一点很难使用;它们仅从其主侧可见。它们是快速、轻量级的对象,但只能有一个纹理贴图,因此如果使用大平面,它们可能看起来重复。如果你把一个平面旋转错了方向,你可能什么也看不见;你可以从后面看。注意变换或使用`Box`而不是`Plane`。 - -更多信息,请参见[https://facebook.github.io/react-vr/docs/plane.html](https://facebook.github.io/react-vr/docs/plane.html) 。 - -与所有 3D 原语一样,`Cylinder`有`lit`、`texture`和`wireframe`道具 - -# 球 - -跟随反弹的球,尽管稍后将介绍动画。与`Cylinder`一样,`Sphere`有一个可以改变其分辨率的道具: - -```jsx - -``` - -与我们制作金字塔的方式类似,在宽度和高度上加入非常少的线段可以使`Sphere`看起来像不同类型的实体。有关更多信息,请参阅[https://facebook.github.io/react-vr/docs/sphere.html](https://facebook.github.io/react-vr/docs/sphere.html) - -与所有 3D 原语一样,`Sphere`有`lit`、`texture`和`wireframe`道具 - -# 模型 - -`Model`组件允许我们做真正有趣的事情。到目前为止,VR 对象相当简单,但模型允许您导入任意复杂度的 CAD 模型。 - -Be careful with `Model`: - -You can easily import objects that are more complex than your platform can handle. Remember, you still maintain the smooth frame rates that are required for Virtual Reality to seem real. - -在[第 6 章](06.html)*中,我们将与 Poly 和 Gon 家族*合作,探讨有效使用`Model`的细节。显示`Model`的基本方法如下: - -`Model`带物料档案: - -```jsx - -``` - -`Model`无材料档案: - -```jsx - -``` - -截至撰写本书时,`Model`导入波前 OBJ 文件格式,以及**GL 传输格式**(**glTF**)。OBJ 是最常见的三维模型格式。人们可能会想,为什么 React 不导入 X3D,这是 WebVR 的首选格式。这是我在 VRML 和 X3D 上投入这么多精力的原因之一 - -在任何情况下,OBJ 文件通常由两个文件组成;`filename.obj`包含对象的几何体,附带的`.MTL`文件(材质)包含颜色、材质和对外部纹理(图像文件)的引用。请注意,这意味着,如果 OBJ 文件在材质文件中加载了许多纹理,则可能需要的不仅仅是这两个文件。 - -我们将在[第 6 章](06.html)*与 Poly 和 Gon 家族*的合作中更深入地介绍这一点。 - -注意,`Model`有`lit`、`texture`和`wireframe`道具。纹理道具应用于整个模型,该模型可能具有多个 UV 映射。通常最好通过`.MTL`文件指定纹理,这可以通过建模程序自动完成。 - -Don't plan on the texture keyword to apply to a `Model` that you have imported. It's far better to texture and map the model in the CAD program you are using, than to try to override it in React VR. - -Secondly, you may need to hand edit the `.MTL` file; my experience is that most exporters can't handle all of the complexity of a nodal-based shader that even real-time engines make dramatic use of; as a result, your `.MTL` file is almost certainly not going to have all of the different baked-in maps. - -# 圆柱状耳 - -`CylindricalPanel`是一个有点过渡的对象。它旨在拥有子对象,并提供在以当前视点为中心的不可见圆柱体上绘制这些对象的功能。它的主要目的是允许在 3D 世界中放置熟悉的 2D 元素。要做到这一点,需要一些不合时宜的因素 - -当您使用 HTML 时,为了精确地布局 HTML 元素,您可能需要考虑并使用像素编码;例如,某个元素可以是 200 像素宽。这允许您精确地布局图形。 - -在 3D 中,这些都不适用。月球是 1、2 还是 10 像素宽?世界上没有每英寸上的*点。因此,大多数虚拟现实原语建立了它们的实际尺寸,以及它们的*虚拟*尺寸,单位为米。然后,您的 VR 显示方法将显示适当数量的像素。如果你把你的头向上移动到那个立方体,它可能是 2000 像素;如果你在走廊尽头看到它,它可能有 10 像素宽。因此,通常情况下,您不会将像素用于 React VR 的大小。* - -然而,`CylindricalPanel`对象*不*需要像素数量的属性。这不是为了对象本身(嗯,有点),而是为了一个屏幕外缓冲区来保存任何子对象的可见渲染。和网络上的许多东西一样,它也有合理的默认值。默认值相当大,但如果你靠近它,它看起来就不那么粗糙了。 - -I highly recommend not using `CylindricalPanel`, but rather recode your UI into actual 3D objects. The resolution and system resource use (RAM mainly) could actually be lower this way. - -例如: - -```jsx - - ... Child components ... - -``` - -The `Child components` line is very important--here is where you put the actual 2D objects that will show up spread across `CylindricalPanel`. It is not literal code. - -# 视频控制 - -`VideoControl`是具有正常`VideoPlayer`功能的物理对象,即启动、暂停等。由于它旨在用于播放视频,因此这里的示例(直接来自文档)将显示它嵌入了动画对象: - -```jsx -class VideoPlayer extends React.Component { -constructor(props) { - super(props); - this.state = { - // init with muted, autoPlay - playerState: new MediaPlayerState({autoPlay: true, muted: true}), - }; -} -render() { - return ( - - - ); -} -} -``` - -不要觉得自己仅限于预期用途。你也可以用它做实验——也许它是一个好的列车控制器! - -# VrButton - -`VrButton`实际上不是一个真正的按钮(好吧,它都是虚拟的,对吧?),这意味着它没有任何几何图形,但它是一个你可能会发现非常有用的对象,可以包含在这个世界中 - -`VrButton`主要用于凝视检测。我们在[第 11 章](11.html)、*野外散步*[中讨论了这一点以及其他 VR 运动(移动)技术。](11.html)现在,让我们来讨论一下`VrButton`是什么: - -```jsx -this._onViewClicked()}> - - - -``` - -这个`VrButton`包装一个图像并播放一个声音。我们将在[第 8 章](08.html)*呼吸生活在你的世界*中进一步讨论声音,但这里简单介绍一下,文件格式允许浏览器决定在你选择的浏览器中播放哪种声音。 - -# 灯 - -如果我们没有灯光,世界将是一个相当黑暗的地方,充满了吸血鬼。让我们把那些不死生物赶走。有四个主要指示灯: - -* 环境光 -* 平行光 -* 点光源节点 -* 聚光灯 - -# 普通光特性 - -所有灯光都有两个公共属性: - -* `intensity`:这就是场景中灯光的亮度。默认值为`{1.0}`,但可以更高。实际上,更高的设置会使对象(例如球体)弯曲边缘上的阴影更锐利,看起来更亮(褪色),但在最亮的面上,阴影实际上不会比白色(RGB 255、255、255)更白。 -* `color`:颜色未列在灯光属性下,但它是所有灯光都具有的样式道具。这是一个 RGB 属性。您甚至可以使用彩色环境光,它可以用于暗褐色色调等,也可以模拟来自明亮颜色环境的背景照明。例如,在森林中,可能是浅绿色环境色。默认值为白色。 - -其他灯光具有特定于其所代表的照明类型的特性。 - -# 环境光 - -`AmbientLight`是使场景可见的最简单方法。它实际上不是灯光,但它确实照亮了场景中的所有东西。 - -现实世界中的照明非常复杂。光子在物体周围反弹、反射、穿透,甚至使某些物体发光(荧光和发光)。一个有用的技巧是,即使没有灯光,也可以使对象变亮,或者为房间添加灯光填充,以帮助模拟背景光散射,而无需计算此项的开销。 - -这称为环境光。许多 CAD 系统将环境作为材料的一种价值。`AmbientLight`让你照亮整个房间。对于喜欢迪斯科、热爱节日的人们来说,它甚至可以让你将颜色从白色变为你想要的任何颜色。现在,你可以制作一个看起来像 W 连锁酒店走廊的场景。 - -奇怪的是,React VR 下载中没有一个示例演示如何使用`AmbientLight`;虽然没那么难,但很重要。 - -以下是环境温度为`.2`的球体的屏幕截图: - -![](img/45b254bb-6a90-4877-bf7e-6328b13b4b47.jpg) - -代码如下: - -```jsx - -``` - -注意一些事情——在最后一张照片中,我们也有一个平行光,所以你可以看到不同之处。从平行光看,球体是白色的,但底部是黑色的,但不是漆黑的。`AmbientLight`可以实时模拟一点全局照明或光能传递。GI 是从其他对象反弹并在真实、非虚拟世界中创建“填充光”的光量。Three.js 也为这个*、*添加了一个`THREE.HemisphereLight`,您可以通过本机视图或本机桥添加它来反应 VR。 - -# 平行光 - -从`AmbientLight`到`DirectionalLight`,我们正在从抽象走向稍微不那么抽象。A`DirectionalLight`真的是用来代替太阳的。太阳光线总是相互平行;同样地,`DirectionalLight`也不会像一盏离得更近的灯那样展开 - -这是一个`DirectionalLight`和编号`AmbientLight`: - -![](img/a2b8bce2-366e-4911-8b04-a71482102329.jpg) - -代码如下: - -```jsx - -``` - -In the picture, we've rotated the `DirectionalLight` to the side slightly; the sphere looks interesting, but not quite right compared to the rest of the scene. This is because the lighting for the Pano background is substantially different than the scene. You would want to try to match the two up with the appropriate transform statement for your ``. - -# 点光源节点 - -A`Pointlight`就像一个老式的灯泡;光从该点向各个方向传播。关于点光源和聚光灯的一个有趣的事情是再次简化,使我们的虚拟现实看起来真实。为了避免渲染速度非常慢,大气没有严格建模。这意味着通常会因大气而褪色的光线会照数英里(除非你住在月球上,否则在我居住的地方,大气效应可能比你居住的地方更重要。如果你住在月球上,给我一张票,我会亲自来给你大声读这本书)。 - -为了避免模拟诸如消光(褪色)、雾、云等大气效应,`PointLight`和`SpotLight`都使用衰减和距离道具。 - -`distance`是光照的距离。如果它不是零,则该距离处的光强度将为零。 - -`decay`是 if 消失的频率。这是一种通用(无量纲)数字;`2`是物理真实的灯光衰减。`0.1`使褪色更加锐利,有助于艺术效果。 - -例如: - -```jsx - -``` - -为了更好地可视化前面的内容,我已经构建了三次演示场景;第一个距离为 10,第二个距离为 4,第三个距离为 4,衰减为`0.1`,而不是 2。你可以看到第三个场景看起来很不自然。请注意,所有三个场景的强度正好为一。 - -![](img/22675a67-ad9c-4ec2-b46e-69cf89bfa6f6.png) - -If your point lights seem dim, check the distance parameter. I recommend leaving decay at two. - -# 聚光灯 - -A`SpotLight`就像那些灯罩一样,它们在黑色电影或手电筒里的坏人脸上发光。像`PointLight`一样,它也有衰减和距离道具(如前所示)。 - -`distance`和`decay`道具与`PointLight`道具相同。`SpotLight`还有`penumbra`和`angle`道具;这两个是光的传播距离。角度是`angle`之外的最大值,`penumbra`是一个从 1 到 100 的数字,定义`SpotLight`的柔软程度。 - -```jsx - -``` - -Currently, the position of the SpotLight defines where the light is shining "from." The target of the light, in other words what it is pointed at, is currently not exposed in React VR. At the time of the writing of this book, this issue is not resolved.  - -Using a View to wrap the SpotLight doesn't seem to change the target either. - -I recommend not using SpotLight, unless you can arrange your scene to have the object of interest located at [0,0,0]. - -# 多媒体-声音和视频 - -如果你什么都听不见,这个世界会很无聊。视频通常是动态网页的一部分,尽管在虚拟现实中,我们有一点挑战——视频本身可能不会吸引人,除非它是 360 视频,有些人称之为虚拟现实(它不能给你更多的是虚无的鬼魂感觉,所以在我看来,它不是真正的虚拟现实,因为你不能完全沉浸其中,但其他人可能会觉得它是虚拟现实。在虚拟现实/AR/XR 中,我们真的需要相处!)。 - -虚拟现实世界中的视频是营造氛围的重要组成部分。如果你走进一个房间,一个视频正在播放,它会看起来更像大多数家庭。 - -# 声音 - -虚拟现实中的`Sound`比最初听起来要复杂得多(双关语)。`Sound`节点允许将音频源放入您的虚拟现实世界。`Sound`将使您的世界充满活力。 - -从反应 VR 手册,考虑一个例子的 T0 T0: - -```jsx - - - -``` - -这个例子展示了通过在 React VR 中声明来添加东西是多么容易。`waterfall`声音简单地附加到`waterfall`图像的位置。如果你在 3D 世界里走来走去,你会*听到*瀑布的声音,仿佛它就在图像所在的地方;这一切都是通过简单地添加`Sound`组件作为叶节点(本例中为图像的子节点)来完成的。`Sound`节点本身不应该有任何子组件。 - -如果`Sound`节点未连接到具有位置的对象,则默认为绝对位置,例如位置:绝对。 - -`Sound`节点有很多道具。详情如下: - -* `autoPlay`:布尔值 - 当加载组件时音频开始自动播放。默认为`true`。 -* `loop`:布尔值 - 当音频播放完毕自动重复时。默认为`false`。 -* `muted`:音频静音时的布尔值 - 。默认为`false`。 -* `onDurationChange`:(回调函数) - 当声音持续时间改变时调用此函数,参数为声音持续时间。 -* `onEnded`:(回调函数) - 音频播放完成后调用函数`onEnded`。 -* `onPlayStatusChange`:(回调函数) - 播放状态改变时调用此函数。`event.nativeEvent.playStatus`:这是声音的播放状态;字符串`'closed'`、`'loading'`、`'error'`、`'ended'`、`'paused'`、`'playing'`或`'ready'`之一。 -* `onTimeUpdate`:(`callback`函数) - 当声音的`currentTime`发生变化时调用此函数。 -* `event.nativeEvent.currentTime`:声音文件的`currentTime`。 -* `playControl`:*播放*、*暂停*或*停止*。 - 此变量控制播放状态。如果未设置,`autoPlay`的值确定加载组件时是否播放音频。 -* `playerState`:(对象) - -`playerState`是一个`MediaPlayerState`,通过其内部状态控制视频播放。设置`playerState`时,`autoPlay`、静音音量和`playControl`属性的值将被忽略,因为它们将由`playerState`设置。参见`MediaPlayerState`。 - -* `source`:(对象) - -对象源音频的格式为{uri:http}。 - -* `volume`:音频音量的 0-1.0(实际不受限制) - 值。最小值为零,使声音静音,建议的最大值为 1.0,这也是默认值。允许大于 1 的值;这可能会导致剪辑/失真,具体取决于音频硬件。 - 示例:将音量降低 50%设置`volume={0.5}`。由于不同的平台可能具有不同的音频功能(叹气),因此源文件可以是几种不同的文件格式,浏览器将选择它可以读取的适当格式。 - -It appears that mono files work best; not all browsers seem to support stereo sound files. This is because the browser will convert the sound to a stereo sound and try to replicate 3D audio (which can be done with only two speakers through a Head Related Transfer Function). - -Use mono files for the best compatibility. - -# 视频 - -因为`Video`只是一个二维(2D)对象,所以它具有宽度和高度。正如您可能习惯的那样,这不是以像素为单位的,而是以世界单位为单位的,原因在前面讨论过。如果人们将视角移近或移远 2D 视频,它将从每英寸点数的角度改变分辨率。您可能需要尝试大小和视频压缩/存储,以找到质量、下载速度和分辨率(颗粒度)的理想平衡 - -`Video`和`VideoControl`一起使用时效果最好(本章前面已经介绍过)。 - -此示例显示了一个`Video`和一个`VideoController`: - -```jsx -