-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdb.json
1 lines (1 loc) · 628 KB
/
db.json
1
{"meta":{"version":1,"warehouse":"4.0.0"},"models":{"Asset":[{"_id":"themes/landscape/source/css/style.styl","path":"css/style.styl","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/blank.gif","path":"fancybox/blank.gif","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/fancybox_loading.gif","path":"fancybox/fancybox_loading.gif","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/[email protected]","path":"fancybox/[email protected]","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/fancybox_overlay.png","path":"fancybox/fancybox_overlay.png","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/fancybox_sprite.png","path":"fancybox/fancybox_sprite.png","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/[email protected]","path":"fancybox/[email protected]","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/jquery.fancybox.css","path":"fancybox/jquery.fancybox.css","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/jquery.fancybox.js","path":"fancybox/jquery.fancybox.js","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/jquery.fancybox.pack.js","path":"fancybox/jquery.fancybox.pack.js","modified":0,"renderable":1},{"_id":"themes/landscape/source/js/jquery.2.0.3..min.js","path":"js/jquery.2.0.3..min.js","modified":0,"renderable":1},{"_id":"themes/landscape/source/js/script.js","path":"js/script.js","modified":0,"renderable":1},{"_id":"themes/landscape/source/css/fonts/FontAwesome.otf","path":"css/fonts/FontAwesome.otf","modified":0,"renderable":1},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.eot","path":"css/fonts/fontawesome-webfont.eot","modified":0,"renderable":1},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.svg","path":"css/fonts/fontawesome-webfont.svg","modified":0,"renderable":1},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.ttf","path":"css/fonts/fontawesome-webfont.ttf","modified":0,"renderable":1},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.woff","path":"css/fonts/fontawesome-webfont.woff","modified":0,"renderable":1},{"_id":"themes/landscape/source/css/images/banner.jpg","path":"css/images/banner.jpg","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/helpers/fancybox_buttons.png","path":"fancybox/helpers/fancybox_buttons.png","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-buttons.css","path":"fancybox/helpers/jquery.fancybox-buttons.css","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-buttons.js","path":"fancybox/helpers/jquery.fancybox-buttons.js","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-media.js","path":"fancybox/helpers/jquery.fancybox-media.js","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-thumbs.css","path":"fancybox/helpers/jquery.fancybox-thumbs.css","modified":0,"renderable":1},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-thumbs.js","path":"fancybox/helpers/jquery.fancybox-thumbs.js","modified":0,"renderable":1},{"_id":"source/images/favicon.ico","path":"images/favicon.ico","modified":0,"renderable":0}],"Cache":[{"_id":"source/_posts/feeling-of-writing.md","hash":"994a5201558ea18e7268637a3e50ac1bcfa7e6aa","modified":1652709728375},{"_id":"source/.DS_Store","hash":"ea9da2a09a799796284d65e1b84ea31ffba34211","modified":1699370349702},{"_id":"source/_posts/gmtc-vr3d.md","hash":"ff50fae4160d8e2a5c64e9bad41ea8b425ba21e3","modified":1699372095533},{"_id":"source/_posts/football-4-me.mdx","hash":"d1ec9b6dcfb36ba172efcac6bb11ecdebd675ba7","modified":1671786161529},{"_id":"source/_posts/a-frog-sound.mdx","hash":"cd85e41b7991c9de27c466b49d8c79ce01be4d13","modified":1637284737043},{"_id":"source/images/favicon.ico","hash":"bdc4c54094d6f7e08125f38e0479d11a6570fdaf","modified":1629593940883},{"_id":"source/_posts/habitat-with-stone.mdx","hash":"b6c29619815a578b4e8b82956ef0eef018947c52","modified":1637284999915},{"_id":"source/_posts/javascript-async.md","hash":"888ac5fe1e8e59940ba59324f4477f79e3cb102a","modified":1699372791406},{"_id":"source/_posts/react-hooks-with-imperativesdk.mdx","hash":"17e3100d95877eda12cf10a5c1f22c2a0417209e","modified":1637284358371},{"_id":"source/_posts/memory-usage-pic.md","hash":"a1d16d4667b2616d0251724917b5afbd7f54bfb0","modified":1699372823760},{"_id":"source/_posts/rocky-sugar.mdx","hash":"59317e2b8e8ec67f64af5635ad8f3d56b968638e","modified":1637284604301},{"_id":"source/_posts/pure-functions-in-fp.md","hash":"b71960839e9fa4136d347a7ead2e69e81836c543","modified":1699372899154},{"_id":"source/_posts/rust-ownership.mdx","hash":"7fa0c1ee226eb02595bf81590ed6fd8dc725c304","modified":1637285169788},{"_id":"source/_posts/realsee-vr-performance.md","hash":"703eb3c540877fb369a00da1629d3b69ea6d1328","modified":1699372917845},{"_id":"source/_posts/rust-trait.mdx","hash":"220be1103236e476aeb2a96df90e738980408d9b","modified":1637285341194},{"_id":"source/_posts/rust-algebraic.mdx","hash":"52f75cf91f735337b82e679c322c12f3678e5276","modified":1637285324995},{"_id":"source/_posts/step-problem-assay.mdx","hash":"84ccfdce6e0c223ab037dd4f2089a0767f2b2ae5","modified":1637284282902},{"_id":"source/_posts/show-icon-in-web.md","hash":"db8df547c25db516d75642f60e73d411737a6e40","modified":1652709756329},{"_id":"source/_posts/tech-salon-13-app-proto.md","hash":"4ac495c7ffdb1a4592f4ecaad20b8acfa3184912","modified":1699372962778},{"_id":"source/_posts/those-years-nba-player.md","hash":"465d10185f27107cc7252c6be4d96d869ebd014a","modified":1699712701007},{"_id":"source/_posts/javascript-async/pingpong.gif","hash":"403fb61fcfc595dbf15548e0c2d6a08c90416d21","modified":1629593940878},{"_id":"source/_posts/gmtc-vr3d/5c7caa3b1c540.jpeg","hash":"1127ce560baf84121f834d2c0e230ca9f03e4f57","modified":1629593940649},{"_id":"source/_posts/pure-functions-in-fp/set_map.svg","hash":"3b6502b2a46585c880aee6ae78ac0ee568cb5f04","modified":1629593940878},{"_id":"source/_posts/realsee-vr-performance/image_01.png","hash":"292da49b17415f8a9d71305787a531c278aa3fd0","modified":1699369069784},{"_id":"source/_posts/realsee-vr-performance/image_02.svg","hash":"fcf90cf7a2a1f42e81a1399f6647484cb894ba71","modified":1699368622933},{"_id":"source/_posts/realsee-vr-performance/image_03.svg","hash":"8d2e2e6945dfbd4244d3fb6e0167e249a9d5c380","modified":1699368622933},{"_id":"source/_posts/realsee-vr-performance/image_06.svg","hash":"794dcc9ab8bad25c753f50c9de0587135a6283cf","modified":1699368622937},{"_id":"source/_posts/tech-salon-13-app-proto/pages-recipes.svg","hash":"c0dc0162cbfd11701ff5d4b9b777146d8695d2b6","modified":1629593940880},{"_id":"source/_posts/tech-salon-13-app-proto/datasources-recipes.png","hash":"c956bb1c25dae441cf9dd3725e0f4d1ae0adc636","modified":1629593940880},{"_id":"source/_posts/tech-salon-13-app-proto/datasources-recipes.svg","hash":"da14575a126f5656c84d977d5f2228f3ea45ce39","modified":1629593940880},{"_id":"source/_posts/tech-salon-13-app-proto/pages-recipes.png","hash":"739ab161816504dd17dd102b4c0389cf377928e3","modified":1629593940880},{"_id":"source/_posts/tech-salon-13-app-proto/static-file-map.svg","hash":"4ea915bfe0f6d5b75d883b3c9536601fb1d575fd","modified":1629593940881},{"_id":"source/_posts/tech-salon-13-app-proto/static-file-map.png","hash":"3de6948d09c7993e48a6913924cf7280633ac59d","modified":1629593940881},{"_id":"source/_posts/tech-salon-13-app-proto/structural-design.svg","hash":"1971439e4edf55e6468035a9550427110068e5bf","modified":1629593940883},{"_id":"source/_posts/gmtc-vr3d/pic19left.png","hash":"d19ee9af247fd2138231ea99f5e70792afe80ad5","modified":1629593940771},{"_id":"source/_posts/gmtc-vr3d/pic19right.png","hash":"5c9f66cc14bd523483f7d12924d6b6494991d80c","modified":1629593940771},{"_id":"source/_posts/gmtc-vr3d/pic9right.png","hash":"c9a7f4296d5549b5f9c3556ea18c5e192c6e15af","modified":1629593940877},{"_id":"source/_posts/tech-salon-13-app-proto/structural-design.png","hash":"cf7bc66248cda7115ddc8d5f1321af03c34e6b3d","modified":1629593940882},{"_id":"source/_posts/gmtc-vr3d/pic3.png","hash":"1738069329e79abc2f83edb687ce87e252c4f11b","modified":1629593940818},{"_id":"source/_posts/realsee-vr-performance/image_04.png","hash":"216d2a9b18ae9b0fefee351c0a9bc7a13cd11aa1","modified":1699369091301},{"_id":"source/_posts/realsee-vr-performance/image_05.png","hash":"eb4d2ad0d6fef518cc816759ff28e1b800bab0ec","modified":1699369100395},{"_id":"source/_posts/gmtc-vr3d/pic16.gif","hash":"bd18ee1fc06c1a8995c7104b80ef15b3dab9c5ad","modified":1629593940759},{"_id":"source/_posts/gmtc-vr3d/pic22.png","hash":"d832c2e1855db4c04d85a60c07a145e14046f265","modified":1629593940794},{"_id":"source/_posts/memory-usage-pic/animation.13cc0efb.png","hash":"60186ebcbcaadc6d8fb9cc6b88c5703d1dc55ece","modified":1652709674844},{"_id":"source/_posts/memory-usage-pic/realseelogo.gif","hash":"5a41aba82ea610355d8654306eb3b7a5f95275f6","modified":1652709674871},{"_id":"source/_posts/gmtc-vr3d/pic1.png","hash":"fdda22cc0310ba193f17fa30b3f7b316ba704395","modified":1629593940649},{"_id":"source/_posts/gmtc-vr3d/pic21.png","hash":"0abcc8aae56a811d8e224b8e830e07b8f26a2085","modified":1629593940792},{"_id":"themes/landscape/.npmignore","hash":"58d26d4b5f2f94c2d02a4e4a448088e4a2527c77","modified":1629593940884},{"_id":"themes/landscape/Gruntfile.js","hash":"71adaeaac1f3cc56e36c49d549b8d8a72235c9b9","modified":1629593940884},{"_id":"themes/landscape/languages/de.yml","hash":"3ebf0775abbee928c8d7bda943c191d166ded0d3","modified":1629593940885},{"_id":"themes/landscape/README.md","hash":"37fae88639ef60d63bd0de22314d7cc4c5d94b07","modified":1629593940885},{"_id":"themes/landscape/LICENSE","hash":"c480fce396b23997ee23cc535518ffaaf7f458f8","modified":1629593940884},{"_id":"themes/landscape/languages/es.yml","hash":"76edb1171b86532ef12cfd15f5f2c1ac3949f061","modified":1629593940886},{"_id":"themes/landscape/_config.yml","hash":"3caf57d46844acacbf3102f55842e2caed763e60","modified":1652708847260},{"_id":"themes/landscape/languages/fr.yml","hash":"415e1c580ced8e4ce20b3b0aeedc3610341c76fb","modified":1629593940886},{"_id":"themes/landscape/package.json","hash":"544f21a0b2c7034998b36ae94dba6e3e0f39f228","modified":1629593940893},{"_id":"themes/landscape/languages/default.yml","hash":"350b49872abc74fba7ece11847ab9dc62e846e55","modified":1629594835353},{"_id":"themes/landscape/languages/ja.yml","hash":"a73e1b9c80fd6e930e2628b393bfe3fb716a21a9","modified":1629593940886},{"_id":"themes/landscape/languages/ko.yml","hash":"881d6a0a101706e0452af81c580218e0bfddd9cf","modified":1629593940886},{"_id":"themes/landscape/languages/nl.yml","hash":"12ed59faba1fc4e8cdd1d42ab55ef518dde8039c","modified":1629593940886},{"_id":"themes/landscape/languages/no.yml","hash":"965a171e70347215ec726952e63f5b47930931ef","modified":1629593940887},{"_id":"themes/landscape/languages/pt.yml","hash":"57d07b75d434fbfc33b0ddb543021cb5f53318a8","modified":1629593940887},{"_id":"themes/landscape/languages/ru.yml","hash":"4fda301bbd8b39f2c714e2c934eccc4b27c0a2b0","modified":1629593940887},{"_id":"themes/landscape/languages/zh-CN.yml","hash":"db70258daa6a9a5b7ad48f516e82cc832758d256","modified":1629593940887},{"_id":"themes/landscape/languages/zh-TW.yml","hash":"53ce3000c5f767759c7d2c4efcaa9049788599c3","modified":1629593940887},{"_id":"themes/landscape/layout/archive.ejs","hash":"2703b07cc8ac64ae46d1d263f4653013c7e1666b","modified":1629593940892},{"_id":"themes/landscape/layout/category.ejs","hash":"765426a9c8236828dc34759e604cc2c52292835a","modified":1629593940892},{"_id":"themes/landscape/layout/index.ejs","hash":"aa1b4456907bdb43e629be3931547e2d29ac58c8","modified":1629593940892},{"_id":"themes/landscape/layout/tag.ejs","hash":"eaa7b4ccb2ca7befb90142e4e68995fb1ea68b2e","modified":1629593940893},{"_id":"themes/landscape/layout/post.ejs","hash":"7d80e4e36b14d30a7cd2ac1f61376d9ebf264e8b","modified":1629593940893},{"_id":"themes/landscape/scripts/fancybox.js","hash":"77e213d51e547818e56927f2a222b6b0f603e964","modified":1629593940894},{"_id":"themes/landscape/layout/layout.ejs","hash":"f155824ca6130080bb057fa3e868a743c69c4cf5","modified":1629593940892},{"_id":"themes/landscape/layout/_partial/archive.ejs","hash":"8fef4f5a084b721a934bfcf0a34c2170b6d7e954","modified":1629593940888},{"_id":"themes/landscape/layout/_partial/after-footer.ejs","hash":"0f2efe089414cbf721b8f020e41aed5bb78cc455","modified":1629593940888},{"_id":"themes/landscape/layout/page.ejs","hash":"7d80e4e36b14d30a7cd2ac1f61376d9ebf264e8b","modified":1629593940893},{"_id":"themes/landscape/layout/_partial/archive-post.ejs","hash":"f34f23cd68f03dd6fb5cdd60e57fee89f0f17e12","modified":1629593940888},{"_id":"themes/landscape/layout/_partial/footer.ejs","hash":"62b0d465a052f8c1b02d3eb29b83fbca29dca97f","modified":1629593940889},{"_id":"themes/landscape/layout/_partial/gauges-analytics.ejs","hash":"aad6312ac197d6c5aaf2104ac863d7eba46b772a","modified":1629593940889},{"_id":"themes/landscape/layout/_partial/article.ejs","hash":"3ccc642df114866b52abd7b5f5df5480cbf6d974","modified":1652708757171},{"_id":"themes/landscape/layout/_partial/google-analytics.ejs","hash":"f921e7f9223d7c95165e0f835f353b2938e40c45","modified":1629593940889},{"_id":"themes/landscape/layout/_partial/head.ejs","hash":"8af9888a96dd45139fecab8b01a47f5c71de4a6e","modified":1699712439535},{"_id":"themes/landscape/layout/_partial/sidebar.ejs","hash":"930da35cc2d447a92e5ee8f835735e6fd2232469","modified":1629593940891},{"_id":"themes/landscape/layout/_partial/header.ejs","hash":"f241489ad93acff3d77ba786a8cdeb806ed2ad61","modified":1699368622949},{"_id":"themes/landscape/layout/_widget/archive.ejs","hash":"beb4a86fcc82a9bdda9289b59db5a1988918bec3","modified":1629593940891},{"_id":"themes/landscape/layout/_partial/mobile-nav.ejs","hash":"6407ef50cc92516e2546f40a0a35ca0a54bbf1e2","modified":1699368622951},{"_id":"themes/landscape/layout/_widget/recent_posts.ejs","hash":"d6c6c4cd3c82ea4448ace77b88d27114685cfd89","modified":1629593940891},{"_id":"themes/landscape/layout/_widget/category.ejs","hash":"dd1e5af3c6af3f5d6c85dfd5ca1766faed6a0b05","modified":1629593940891},{"_id":"themes/landscape/layout/_widget/tag.ejs","hash":"2de380865df9ab5f577f7d3bcadf44261eb5faae","modified":1629593940892},{"_id":"themes/landscape/layout/_widget/tagcloud.ejs","hash":"b4a2079101643f63993dcdb32925c9b071763b46","modified":1629593940892},{"_id":"themes/landscape/source/css/_extend.styl","hash":"c560f79a127f6fb658741096bccdac9895df978f","modified":1699368622952},{"_id":"themes/landscape/source/css/_variables.styl","hash":"5f7efdda8ffedcf9820baaf8a82af952008584d2","modified":1699368622955},{"_id":"themes/landscape/source/css/style.styl","hash":"2f1ad608e35f4b359049180c7c1e5359ed1705f1","modified":1699543618065},{"_id":"themes/landscape/source/fancybox/blank.gif","hash":"2daeaa8b5f19f0bc209d976c02bd6acb51b00b0a","modified":1629593940905},{"_id":"themes/landscape/source/fancybox/fancybox_loading.gif","hash":"1a755fb2599f3a313cc6cfdb14df043f8c14a99c","modified":1629593940905},{"_id":"themes/landscape/source/fancybox/fancybox_overlay.png","hash":"b3a4ee645ba494f52840ef8412015ba0f465dbe0","modified":1629593940906},{"_id":"themes/landscape/source/fancybox/[email protected]","hash":"273b123496a42ba45c3416adb027cd99745058b0","modified":1629593940905},{"_id":"themes/landscape/source/fancybox/[email protected]","hash":"30c58913f327e28f466a00f4c1ac8001b560aed8","modified":1629593940906},{"_id":"themes/landscape/source/fancybox/fancybox_sprite.png","hash":"17df19f97628e77be09c352bf27425faea248251","modified":1629593940906},{"_id":"themes/landscape/source/fancybox/jquery.fancybox.css","hash":"aaa582fb9eb4b7092dc69fcb2d5b1c20cca58ab6","modified":1629593940907},{"_id":"themes/landscape/source/fancybox/jquery.fancybox.js","hash":"d08b03a42d5c4ba456ef8ba33116fdbb7a9cabed","modified":1629593940907},{"_id":"themes/landscape/layout/_partial/post/category.ejs","hash":"c6bcd0e04271ffca81da25bcff5adf3d46f02fc0","modified":1629593940890},{"_id":"themes/landscape/source/fancybox/jquery.fancybox.pack.js","hash":"9e0d51ca1dbe66f6c0c7aefd552dc8122e694a6e","modified":1629593940907},{"_id":"themes/landscape/layout/_partial/post/date.ejs","hash":"4af9a9fc7a8f851f216a2394414bdba409ae7b0d","modified":1629593940890},{"_id":"themes/landscape/layout/_partial/post/gallery.ejs","hash":"3d9d81a3c693ff2378ef06ddb6810254e509de5b","modified":1629593940890},{"_id":"themes/landscape/layout/_partial/post/datesmpl.ejs","hash":"95da5a72a401a7b9a2ba6da9cf9e7bd45e060882","modified":1629593940890},{"_id":"themes/landscape/layout/_partial/post/tag.ejs","hash":"2fcb0bf9c8847a644167a27824c9bb19ac74dd14","modified":1629593940891},{"_id":"themes/landscape/layout/_partial/post/title.ejs","hash":"2f275739b6f1193c123646a5a31f37d48644c667","modified":1629593940891},{"_id":"themes/landscape/source/js/script.js","hash":"0dcb8c8e11eacc8e4c86af54767ca8210133956e","modified":1629593940908},{"_id":"themes/landscape/layout/_partial/post/nav.ejs","hash":"16a904de7bceccbb36b4267565f2215704db2880","modified":1629593940891},{"_id":"themes/landscape/source/css/_partial/archive.styl","hash":"d5a284037e5c68250083ad161c320f92952fd9b8","modified":1629593940894},{"_id":"themes/landscape/source/css/_partial/comment.styl","hash":"79d280d8d203abb3bd933ca9b8e38c78ec684987","modified":1629593940895},{"_id":"themes/landscape/source/css/_partial/footer.styl","hash":"eb05d50fa73b7229cfd431e1f62334d29a854c24","modified":1629593940895},{"_id":"themes/landscape/source/css/_partial/article.styl","hash":"45640df102bb7c2e478bf830c23ee6f10dab902e","modified":1699368622952},{"_id":"themes/landscape/source/css/_partial/header.styl","hash":"cad3a01d8b87c6f2d1c7648c79bc22569a49536e","modified":1699368622953},{"_id":"themes/landscape/source/css/_partial/sidebar-aside.styl","hash":"c444dee9530e1ce233ea910a7078b7d2e4564dae","modified":1699368622954},{"_id":"themes/landscape/source/css/_partial/hljsgithub.styl","hash":"f6382725c56e82f420c7088778f2713238b430a3","modified":1652709674882},{"_id":"themes/landscape/source/css/_partial/highlight.styl","hash":"276d6393ccf4b415e6f17b8354453b84924e69a5","modified":1652709674881},{"_id":"themes/landscape/source/css/_partial/mobile.styl","hash":"16b47ed3639c82d756b53bd184fb3a9c8b85998a","modified":1629593940895},{"_id":"themes/landscape/source/css/_partial/sidebar.styl","hash":"b85537adf946b0e3190d56041dedb0f5e7a3f383","modified":1699368622954},{"_id":"themes/landscape/source/css/_partial/sidebar-bottom.styl","hash":"69e42291e85a453faff2c4ba203e24ceab456cc5","modified":1629593940896},{"_id":"themes/landscape/source/css/_util/grid.styl","hash":"0bf55ee5d09f193e249083602ac5fcdb1e571aed","modified":1629593940896},{"_id":"themes/landscape/source/css/_util/mixin.styl","hash":"44f32767d9fd3c1c08a60d91f181ee53c8f0dbb3","modified":1629593940896},{"_id":"themes/landscape/source/css/fonts/FontAwesome.otf","hash":"b5b4f9be85f91f10799e87a083da1d050f842734","modified":1629593940897},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.eot","hash":"7619748fe34c64fb157a57f6d4ef3678f63a8f5e","modified":1629593940898},{"_id":"themes/landscape/source/fancybox/helpers/fancybox_buttons.png","hash":"e385b139516c6813dcd64b8fc431c364ceafe5f3","modified":1629593940906},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-buttons.js","hash":"dc3645529a4bf72983a39fa34c1eb9146e082019","modified":1629593940906},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-buttons.css","hash":"1a9d8e5c22b371fcc69d4dbbb823d9c39f04c0c8","modified":1629593940906},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.woff","hash":"04c3bf56d87a0828935bd6b4aee859995f321693","modified":1629593940902},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-media.js","hash":"294420f9ff20f4e3584d212b0c262a00a96ecdb3","modified":1629593940906},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-thumbs.css","hash":"4ac329c16a5277592fc12a37cca3d72ca4ec292f","modified":1629593940907},{"_id":"themes/landscape/source/fancybox/helpers/jquery.fancybox-thumbs.js","hash":"47da1ae5401c24b5c17cc18e2730780f5c1a7a0c","modified":1629593940907},{"_id":"source/_posts/gmtc-vr3d/pic20left.png","hash":"d8e4bac3c4170eb9124932b1ca570b7f819176b9","modified":1629593940775},{"_id":"themes/landscape/source/js/jquery.2.0.3..min.js","hash":"a6eedf84389e1bc9f757bc2d19538f8c8d1cae9d","modified":1629593940908},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.ttf","hash":"7f09c97f333917034ad08fa7295e916c9f72fd3f","modified":1629593940900},{"_id":"source/_posts/gmtc-vr3d/pic7.png","hash":"0e343d17b6940c63e232a7576e45c4faf4a36bea","modified":1629593940846},{"_id":"source/_posts/gmtc-vr3d/pic6.png","hash":"39e857595243c18a5a47f773a417e764b5ae6cbe","modified":1629593940843},{"_id":"source/_posts/memory-usage-pic/memo-vr.png","hash":"63fd5931533006607ee7f7e296d3dac854e5b0b1","modified":1652709674847},{"_id":"themes/landscape/source/css/fonts/fontawesome-webfont.svg","hash":"46fcc0194d75a0ddac0a038aee41b23456784814","modified":1629593940899},{"_id":"themes/landscape/source/css/images/banner.jpg","hash":"f44aa591089fcb3ec79770a1e102fd3289a7c6a6","modified":1629593940903},{"_id":"source/_posts/gmtc-vr3d/pic10.gif","hash":"05e80c514770568e5fe0a0ba199296b4e22df23e","modified":1629593940653},{"_id":"source/_posts/gmtc-vr3d/pic18.png","hash":"73b6b8425c7d0342ba207888db019001fcd97145","modified":1629593940769},{"_id":"source/_posts/gmtc-vr3d/pic13left.png","hash":"7968a4ac3682974acb8c470a25ec82842a81a657","modified":1629593940718},{"_id":"source/_posts/gmtc-vr3d/pic14left.png","hash":"0b02f73382d9036c7a4384c3e32cb5295d6d14b3","modified":1629593940732},{"_id":"source/_posts/gmtc-vr3d/pic15.png","hash":"cc2f2fac5adcc3cacc4a5c530f3e58e3d7cb032e","modified":1629593940757},{"_id":"source/_posts/gmtc-vr3d/pic17.gif","hash":"9f11d63c3e1c40eda19f8d92a3101315917e3435","modified":1629593940764},{"_id":"source/_posts/gmtc-vr3d/pic4right.png","hash":"be661a32993b55b22239bdb517805a9faade2508","modified":1629593940828},{"_id":"source/_posts/memory-usage-pic/pano.png","hash":"03faf231317fd48762f6a3d840868aad4c56aa58","modified":1652709674869},{"_id":"source/_posts/gmtc-vr3d/pic8.png","hash":"68934d892a21f25d8a153e2179a2be249a57cf16","modified":1629593940856},{"_id":"source/_posts/gmtc-vr3d/pic12left.gif","hash":"f4a5fcfe62808bd27f5ecae1502498a29ab3082a","modified":1629593940691},{"_id":"source/_posts/gmtc-vr3d/pic12center.gif","hash":"07a59ed66fa8cb274be47572086a072e8dc15209","modified":1629593940680},{"_id":"source/_posts/gmtc-vr3d/pic4left.gif","hash":"3c29fc4584c90e5f5d7324fc9777556d0c3eb74f","modified":1629593940821},{"_id":"source/_posts/gmtc-vr3d/pic5left.gif","hash":"b01db1a1a82daab410278a73ea5c98a0c2b560d8","modified":1629593940834},{"_id":"source/_posts/gmtc-vr3d/pic5right.gif","hash":"b01db1a1a82daab410278a73ea5c98a0c2b560d8","modified":1629593940840},{"_id":"source/_posts/gmtc-vr3d/pic2left.gif","hash":"1f91b20761e2780aa91b4bf3bdc517f9f0ccbde6","modified":1629593940805},{"_id":"source/_posts/gmtc-vr3d/pic14right2.gif","hash":"14e1a50593ecb5a0cfc1db8d121989036278bb11","modified":1629593940753},{"_id":"source/_posts/memory-usage-pic/model.png","hash":"84aa860b832af1b711a57d15c9f71d56c7e25b53","modified":1652709674862},{"_id":"source/_posts/gmtc-vr3d/pic14right1.gif","hash":"de5e2465e20c97a8d55282bf75233dcf9a277d78","modified":1629593940744},{"_id":"source/_posts/gmtc-vr3d/pic2right.gif","hash":"7b6df0fe7e507f08ff43cc2334fad9907e99d3b5","modified":1629593940814},{"_id":"source/_posts/realsee-vr-performance/live_sync.7b9ea663.mp4","hash":"f06b71aec65b3176ce204f15c667715cf78b7640","modified":1699368622944},{"_id":"source/_posts/gmtc-vr3d/pic20right.gif","hash":"7a4629183d8f06edc77fecbd71bdb92936b4447d","modified":1629593940788},{"_id":"source/_posts/gmtc-vr3d/pic11.gif","hash":"c83eb02086abd2db5e26ded66ab5d81230d56ca2","modified":1629593940671},{"_id":"source/_posts/gmtc-vr3d/pic13right.gif","hash":"3ae8b4dde03b7a6e3381344887c6529a3fc06320","modified":1629593940726},{"_id":"source/_posts/gmtc-vr3d/pic12right.gif","hash":"2c60a55d62063e4cbd4b9c163abd7adbb5a2983b","modified":1629593940711},{"_id":"source/_posts/gmtc-vr3d/pic9left.gif","hash":"824cef6b61f276a5b9b36537c666d58c8b2b5392","modified":1629593940869},{"_id":"source/_posts/realsee-vr-performance/1657609527484.mp4","hash":"73401e5880034ca62e70957695295293bab9559f","modified":1699368622930},{"_id":"public/feeling-of-writing/index.html","hash":"c7b441eaa0cdf57ebf22699e2e841a05cef645e8","modified":1699718429008},{"_id":"public/categories/日常碎碎唸/index.html","hash":"0b61ce01c47effb787f433b61dbdafc0e4f521ee","modified":1699718429008},{"_id":"public/categories/学习札記/index.html","hash":"e637de457bd3824bd0b9ff80251c1d7370bec909","modified":1699718429008},{"_id":"public/categories/技术分享/index.html","hash":"c5e8e5849da9714a40d8142a58f2a9f88173c537","modified":1699718429008},{"_id":"public/categories/技术总结/index.html","hash":"4f6c86e68d9c86417a9a12410fa6053b607d9b80","modified":1699718429008},{"_id":"public/archives/index.html","hash":"5e54da70c505404458811b579ead9f601912363d","modified":1699718429008},{"_id":"public/archives/2014/index.html","hash":"c3f7281270b15169cb8cb9f6bc733a2787431138","modified":1699718429008},{"_id":"public/archives/2014/03/index.html","hash":"13562756c7530acaf5eba919af4f0fabc0585355","modified":1699718429008},{"_id":"public/archives/2015/index.html","hash":"6eab47d9c47d1abdf6bc282a4510352c38ae9920","modified":1699718429008},{"_id":"public/archives/2015/02/index.html","hash":"5bc7fa56d2e32f9614b977f7c871d7e811635392","modified":1699718429008},{"_id":"public/archives/2016/index.html","hash":"30a20980ebee67c26d5c10445998930041bf53ac","modified":1699718429008},{"_id":"public/archives/2016/09/index.html","hash":"b3edf0833efda52054384fe64c107b63bb41baa4","modified":1699718429008},{"_id":"public/archives/2016/12/index.html","hash":"bba6c8e71e54ae3d9d0eecfe10a3e375c50c1368","modified":1699718429008},{"_id":"public/archives/2017/index.html","hash":"5c2993f2e47bfd7d7293a519c1ae071b5034383c","modified":1699718429008},{"_id":"public/archives/2017/01/index.html","hash":"b9954983d159429f3f171ac4ce5e548d684df6a7","modified":1699718429008},{"_id":"public/archives/2019/index.html","hash":"b549950030f2e4a9719da81943942c161ccb7ef2","modified":1699718429008},{"_id":"public/archives/2019/11/index.html","hash":"907e7b702590ae0081215ed7346cbf781b97a159","modified":1699718429008},{"_id":"public/archives/2021/index.html","hash":"e34aed50dfc71f1c3afe56b1bf958e6da495ccc7","modified":1699718429008},{"_id":"public/archives/2021/07/index.html","hash":"bc56f7ee57c51abc3ecd098b6ae5c07c28771036","modified":1699718429008},{"_id":"public/archives/2022/index.html","hash":"842e44fd91d595e3d8676a8ddc6af963f2e02a86","modified":1699718429008},{"_id":"public/archives/2022/03/index.html","hash":"444190e2c5592ede0ea122b1d1760c50f43389d1","modified":1699718429008},{"_id":"public/archives/2023/index.html","hash":"4ffad340ab5aa3b64bc5c4737d23672bb97ad248","modified":1699718429008},{"_id":"public/archives/2023/10/index.html","hash":"39a92cc6f3ac0d74034cf8b53d458bf50d2d4a72","modified":1699718429008},{"_id":"public/realsee-vr-performance/index.html","hash":"0c53d4f22e87a0d6ec5b5f60d9942b1640332b0e","modified":1699718429008},{"_id":"public/memory-usage-pic/index.html","hash":"bd9a0ebcc376166d383393ffd4abdc85f160d56d","modified":1699718429008},{"_id":"public/gmtc-vr3d/index.html","hash":"192983759d17915ca88f642c77f69ef227fcf005","modified":1699718429008},{"_id":"public/those-years-nba-player/index.html","hash":"c37285f859c2c3ee6ab771733d44956ba4a850f4","modified":1699718429008},{"_id":"public/tech-salon-13-app-proto/index.html","hash":"4b557c4caf60ed97d6633996cd2f3d24ff53618c","modified":1699718429008},{"_id":"public/javascript-async/index.html","hash":"4693099daa1ef510a6cf3b5508bd39c55fbc95d1","modified":1699718429008},{"_id":"public/pure-functions-in-fp/index.html","hash":"2324d05808e5361fcd743af096cb6d45a4be3a83","modified":1699718429008},{"_id":"public/show-icon-in-web/index.html","hash":"b326897eb06ac96aabfb355d38fbf2f892c132d5","modified":1699718429008},{"_id":"public/index.html","hash":"4cd48b5457b41b5c464f8fb6ba27022214034fa0","modified":1699718429008},{"_id":"public/page/2/index.html","hash":"3cf6251233d8967d62fc4273de839fd85c3117af","modified":1699718429008},{"_id":"public/page/3/index.html","hash":"64c2c0e1f12832fac6372dcdd0e1cc36a3d6c8c9","modified":1699718429008},{"_id":"public/fancybox/blank.gif","hash":"2daeaa8b5f19f0bc209d976c02bd6acb51b00b0a","modified":1699718429008},{"_id":"public/fancybox/fancybox_loading.gif","hash":"1a755fb2599f3a313cc6cfdb14df043f8c14a99c","modified":1699718429008},{"_id":"public/fancybox/fancybox_sprite.png","hash":"17df19f97628e77be09c352bf27425faea248251","modified":1699718429008},{"_id":"public/fancybox/[email protected]","hash":"30c58913f327e28f466a00f4c1ac8001b560aed8","modified":1699718429008},{"_id":"public/fancybox/fancybox_overlay.png","hash":"b3a4ee645ba494f52840ef8412015ba0f465dbe0","modified":1699718429008},{"_id":"public/fancybox/[email protected]","hash":"273b123496a42ba45c3416adb027cd99745058b0","modified":1699718429008},{"_id":"public/css/fonts/fontawesome-webfont.eot","hash":"7619748fe34c64fb157a57f6d4ef3678f63a8f5e","modified":1699718429008},{"_id":"public/css/fonts/FontAwesome.otf","hash":"b5b4f9be85f91f10799e87a083da1d050f842734","modified":1699718429008},{"_id":"public/css/fonts/fontawesome-webfont.woff","hash":"04c3bf56d87a0828935bd6b4aee859995f321693","modified":1699718429008},{"_id":"public/images/favicon.ico","hash":"bdc4c54094d6f7e08125f38e0479d11a6570fdaf","modified":1699718429008},{"_id":"public/fancybox/helpers/fancybox_buttons.png","hash":"e385b139516c6813dcd64b8fc431c364ceafe5f3","modified":1699718429008},{"_id":"public/css/fonts/fontawesome-webfont.ttf","hash":"7f09c97f333917034ad08fa7295e916c9f72fd3f","modified":1699718429008},{"_id":"public/fancybox/jquery.fancybox.css","hash":"aaa582fb9eb4b7092dc69fcb2d5b1c20cca58ab6","modified":1699718429008},{"_id":"public/fancybox/helpers/jquery.fancybox-buttons.js","hash":"dc3645529a4bf72983a39fa34c1eb9146e082019","modified":1699718429008},{"_id":"public/fancybox/helpers/jquery.fancybox-buttons.css","hash":"1a9d8e5c22b371fcc69d4dbbb823d9c39f04c0c8","modified":1699718429008},{"_id":"public/fancybox/helpers/jquery.fancybox-media.js","hash":"294420f9ff20f4e3584d212b0c262a00a96ecdb3","modified":1699718429008},{"_id":"public/fancybox/helpers/jquery.fancybox-thumbs.css","hash":"4ac329c16a5277592fc12a37cca3d72ca4ec292f","modified":1699718429008},{"_id":"public/js/script.js","hash":"0dcb8c8e11eacc8e4c86af54767ca8210133956e","modified":1699718429008},{"_id":"public/fancybox/helpers/jquery.fancybox-thumbs.js","hash":"47da1ae5401c24b5c17cc18e2730780f5c1a7a0c","modified":1699718429008},{"_id":"public/css/style.css","hash":"5aae050f20b91de7d2f6caef42085ab67b1b9cfa","modified":1699718429008},{"_id":"public/fancybox/jquery.fancybox.js","hash":"d08b03a42d5c4ba456ef8ba33116fdbb7a9cabed","modified":1699718429008},{"_id":"public/fancybox/jquery.fancybox.pack.js","hash":"9e0d51ca1dbe66f6c0c7aefd552dc8122e694a6e","modified":1699718429008},{"_id":"public/js/jquery.2.0.3..min.js","hash":"a6eedf84389e1bc9f757bc2d19538f8c8d1cae9d","modified":1699718429008},{"_id":"public/css/fonts/fontawesome-webfont.svg","hash":"46fcc0194d75a0ddac0a038aee41b23456784814","modified":1699718429008},{"_id":"public/css/images/banner.jpg","hash":"f44aa591089fcb3ec79770a1e102fd3289a7c6a6","modified":1699718429008},{"_id":"source/_posts/beijing-wildlife-park-play.md","hash":"528444b53f631fc51f279d68c62409d7d2c41669","modified":1699715192310},{"_id":"public/beijing-wildlife-park-play/index.html","hash":"c8ce974753b693c0310e4a265f6ac0fad21e8a0b","modified":1699718429008},{"_id":"public/page/4/index.html","hash":"c52a852dd618e4ae74e92d76b12435d6f16941df","modified":1699718429008},{"_id":"public/archives/2023/03/index.html","hash":"39c0db52b11694f52b21a0b7bbd96dd49f5945aa","modified":1699718429008},{"_id":"source/_posts/habitat-stone.mdx","hash":"b6c29619815a578b4e8b82956ef0eef018947c52","modified":1637284999915}],"Category":[{"name":"日常碎碎唸","_id":"clooia2lw0002ln3ye5m9effn"},{"name":"技术分享","_id":"clooia2lz0006ln3y39m18ko5"},{"name":"学习札記","_id":"clooia2m1000aln3ye8u809wv"},{"name":"技术总结","_id":"clooia2m2000dln3y4wr0agm1"},{"name":"游记,大大的世界,小小的我","_id":"clookt7e70002ku3y7pvpee87"},{"name":"游记;大大的世界,小小的我","_id":"clooktijp0004ku3ycj0j637k"},{"name":"游记;大大的世界,小小的我","_id":"clooktlwo0006ku3yb57ca90x"},{"name":"大大的世界,小小的我","_id":"clooktuqj0008ku3y9eg10u4i"}],"Data":[],"Page":[],"Post":[{"layout":"layout/post","title":"写字的感觉","date":"2014-03-12T15:30:00.000Z","comments":1,"_content":"\n以前很在意自己写字的感觉,很惬意那意外的享受。\n\n最近买了一款比较昂贵的钢笔,不为别的,仅仅是钢笔的外观很好看,吸引于我。拿到笔后,放进墨汁,手感远没有想象中的好;抑或是久未触笔的我,已经没了那凌驾于纸间的自在。\n\n记得那个忘不了的季节,在书店里,什么奇怪的书籍都不在意,只是在漫漫的字帖中挖掘心仪的那些字体。拿着奇奇怪怪的字帖,一笔一笔地,享受着满足的成就感。使用的钢笔只是一种垃圾货,却依旧当作宝;忍不得别人的触碰,倍加爱护。那时的我知道,要写好字,就的要有一支完美的笔。仅此而已。\n\n意料之中的故事,我最初的审美观确是让人无法理解的\"草书\"。无论怎样,我随时向我身边的人炫耀着或许只有自己能看明白的字体。最终,启蒙老师无法忍受了。可是我依旧我行我素。我说,你们不懂;他说,不是不懂,而是太懂。\n\n后来,后来就没有了。我忘记了最后一次和那位老师是如何交流的;离开学校,也就离开了他;更加遗憾难过的是,曾经待了七年的学校在生源不足的情况下,关闭了。曾经,离开的时候,心里暗暗说过,等我以后,在某一日,我会回来让学校变得更好。最近一次去学校的时候,其实是我酿造的意外,教学楼什么的都没变,只是操场上布满了杂草。曾经,和别人打过架的操场;曾经,在这里被授予过骄傲小红花的操场;曾经,在这里排好队后回家的操场。你最大的失败,是无法改变;一件事,你不能改变结局,那就是一件无意义的事;即使,有时你能猜到结局。\n\n> \"你所创造的是一项伟大的艺术品,而非一堆堆垃圾。\"\n\n不知道,这是谁说过的话;不知晓,抑或是我在某本书看到的。不知如何,我在父亲的手里获得一支我很满意的钢笔。我是不在乎细节的,那只钢笔陪伴了我许久,直至丢失的时候,我沉寂了许久。我开始告别以前的潦草,只是因为我发现和我字体类似人实在太多。不爱和别人一样。其实,在某种程度上,是喜欢上了同桌的字体。她写的字和她的人一样,就是这样的一种感觉。原来在通过手书写出的文字中也能找到某种异样的感觉;而且,除了你很难会有其他人知道。\n\n有时候,改变是痛苦的,尤其是改变叫\"习惯\"的那东西;有时候,改变只是谋求换一种感觉。有同学说过,我写作文的速度比他抄的速度还快;只为完成作业的他,怎么会懂?我只是在追求那种写字的感觉。其实,字无须写的有多么好,你要能让你想给她看的人能识别就好;可是,那种写字的感觉,那种心情只有你自己才知道。君不知,能读懂你写字心态的人真不多。\n\n步入大学后,学上计算机后的我,打字速度一直在进步。从刚开始依赖于 word 文档,到现在整天在 HTML 和 CSS 中摸索,以创造更美的视觉效果。与写字的感觉渐行渐远,近乎忘却。\n\n老家的枣树每年都会结很多枣,回老家打枣几乎成为回老家唯一的理由。曾经的玩伴,现在的你们都在做些什么;很遗憾的是,我已经忘记了你们那些亲切的名字;颇具讽刺的是,那些和我打过架的,我都还记得;或许,打与被打才会留下痕迹。\n\n曾听离校学长说过,成功的大学经历,是在你离开的时候和你进来的时候心态是一样的;如果不一样,其实,你已学会了成长,喝下了大学的毒。\n\n曾经的好友埋怨过我,\"不是说好的吗你以后要做一名作家?\"确实,其实现在学习计算机的我依旧未变。以前是用文字倾诉心情,而如今,用代码传递意外。只是观众少了许多,抑或是对观众的要求更高了。\n\n有多少人还能记得写字的感觉呢?曾经的朋友是说过,我背叛了她,说好的,你未来是要做作家的!其实,她不懂,敲代码与构思文字是一样的,只是换了一种方式宣泄情绪。永远不会懂。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//i.loli.net/2019/11/13/Qaq9yVK28W1wtZB.png\" alt=\"銀杏碎葉\" />\n <figcaption>銀杏碎葉・2014秋,拍摄于北京奥森公园</figcaption>\n</figure>\n","source":"_posts/feeling-of-writing.md","raw":"---\nlayout: layout/post\ntitle: '写字的感觉'\ndate: 2014-03-12 23:30:00 +0800\ncomments: true\ncategories: 日常碎碎唸\n---\n\n以前很在意自己写字的感觉,很惬意那意外的享受。\n\n最近买了一款比较昂贵的钢笔,不为别的,仅仅是钢笔的外观很好看,吸引于我。拿到笔后,放进墨汁,手感远没有想象中的好;抑或是久未触笔的我,已经没了那凌驾于纸间的自在。\n\n记得那个忘不了的季节,在书店里,什么奇怪的书籍都不在意,只是在漫漫的字帖中挖掘心仪的那些字体。拿着奇奇怪怪的字帖,一笔一笔地,享受着满足的成就感。使用的钢笔只是一种垃圾货,却依旧当作宝;忍不得别人的触碰,倍加爱护。那时的我知道,要写好字,就的要有一支完美的笔。仅此而已。\n\n意料之中的故事,我最初的审美观确是让人无法理解的\"草书\"。无论怎样,我随时向我身边的人炫耀着或许只有自己能看明白的字体。最终,启蒙老师无法忍受了。可是我依旧我行我素。我说,你们不懂;他说,不是不懂,而是太懂。\n\n后来,后来就没有了。我忘记了最后一次和那位老师是如何交流的;离开学校,也就离开了他;更加遗憾难过的是,曾经待了七年的学校在生源不足的情况下,关闭了。曾经,离开的时候,心里暗暗说过,等我以后,在某一日,我会回来让学校变得更好。最近一次去学校的时候,其实是我酿造的意外,教学楼什么的都没变,只是操场上布满了杂草。曾经,和别人打过架的操场;曾经,在这里被授予过骄傲小红花的操场;曾经,在这里排好队后回家的操场。你最大的失败,是无法改变;一件事,你不能改变结局,那就是一件无意义的事;即使,有时你能猜到结局。\n\n> \"你所创造的是一项伟大的艺术品,而非一堆堆垃圾。\"\n\n不知道,这是谁说过的话;不知晓,抑或是我在某本书看到的。不知如何,我在父亲的手里获得一支我很满意的钢笔。我是不在乎细节的,那只钢笔陪伴了我许久,直至丢失的时候,我沉寂了许久。我开始告别以前的潦草,只是因为我发现和我字体类似人实在太多。不爱和别人一样。其实,在某种程度上,是喜欢上了同桌的字体。她写的字和她的人一样,就是这样的一种感觉。原来在通过手书写出的文字中也能找到某种异样的感觉;而且,除了你很难会有其他人知道。\n\n有时候,改变是痛苦的,尤其是改变叫\"习惯\"的那东西;有时候,改变只是谋求换一种感觉。有同学说过,我写作文的速度比他抄的速度还快;只为完成作业的他,怎么会懂?我只是在追求那种写字的感觉。其实,字无须写的有多么好,你要能让你想给她看的人能识别就好;可是,那种写字的感觉,那种心情只有你自己才知道。君不知,能读懂你写字心态的人真不多。\n\n步入大学后,学上计算机后的我,打字速度一直在进步。从刚开始依赖于 word 文档,到现在整天在 HTML 和 CSS 中摸索,以创造更美的视觉效果。与写字的感觉渐行渐远,近乎忘却。\n\n老家的枣树每年都会结很多枣,回老家打枣几乎成为回老家唯一的理由。曾经的玩伴,现在的你们都在做些什么;很遗憾的是,我已经忘记了你们那些亲切的名字;颇具讽刺的是,那些和我打过架的,我都还记得;或许,打与被打才会留下痕迹。\n\n曾听离校学长说过,成功的大学经历,是在你离开的时候和你进来的时候心态是一样的;如果不一样,其实,你已学会了成长,喝下了大学的毒。\n\n曾经的好友埋怨过我,\"不是说好的吗你以后要做一名作家?\"确实,其实现在学习计算机的我依旧未变。以前是用文字倾诉心情,而如今,用代码传递意外。只是观众少了许多,抑或是对观众的要求更高了。\n\n有多少人还能记得写字的感觉呢?曾经的朋友是说过,我背叛了她,说好的,你未来是要做作家的!其实,她不懂,敲代码与构思文字是一样的,只是换了一种方式宣泄情绪。永远不会懂。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//i.loli.net/2019/11/13/Qaq9yVK28W1wtZB.png\" alt=\"銀杏碎葉\" />\n <figcaption>銀杏碎葉・2014秋,拍摄于北京奥森公园</figcaption>\n</figure>\n","slug":"feeling-of-writing","published":1,"updated":"2022-05-16T14:02:08.375Z","photos":[],"link":"","_id":"clooia2lr0000ln3y89ufbnrc","content":"<p>以前很在意自己写字的感觉,很惬意那意外的享受。</p>\n<p>最近买了一款比较昂贵的钢笔,不为别的,仅仅是钢笔的外观很好看,吸引于我。拿到笔后,放进墨汁,手感远没有想象中的好;抑或是久未触笔的我,已经没了那凌驾于纸间的自在。</p>\n<p>记得那个忘不了的季节,在书店里,什么奇怪的书籍都不在意,只是在漫漫的字帖中挖掘心仪的那些字体。拿着奇奇怪怪的字帖,一笔一笔地,享受着满足的成就感。使用的钢笔只是一种垃圾货,却依旧当作宝;忍不得别人的触碰,倍加爱护。那时的我知道,要写好字,就的要有一支完美的笔。仅此而已。</p>\n<p>意料之中的故事,我最初的审美观确是让人无法理解的"草书"。无论怎样,我随时向我身边的人炫耀着或许只有自己能看明白的字体。最终,启蒙老师无法忍受了。可是我依旧我行我素。我说,你们不懂;他说,不是不懂,而是太懂。</p>\n<p>后来,后来就没有了。我忘记了最后一次和那位老师是如何交流的;离开学校,也就离开了他;更加遗憾难过的是,曾经待了七年的学校在生源不足的情况下,关闭了。曾经,离开的时候,心里暗暗说过,等我以后,在某一日,我会回来让学校变得更好。最近一次去学校的时候,其实是我酿造的意外,教学楼什么的都没变,只是操场上布满了杂草。曾经,和别人打过架的操场;曾经,在这里被授予过骄傲小红花的操场;曾经,在这里排好队后回家的操场。你最大的失败,是无法改变;一件事,你不能改变结局,那就是一件无意义的事;即使,有时你能猜到结局。</p>\n<blockquote>\n<p>"你所创造的是一项伟大的艺术品,而非一堆堆垃圾。"</p>\n</blockquote>\n<p>不知道,这是谁说过的话;不知晓,抑或是我在某本书看到的。不知如何,我在父亲的手里获得一支我很满意的钢笔。我是不在乎细节的,那只钢笔陪伴了我许久,直至丢失的时候,我沉寂了许久。我开始告别以前的潦草,只是因为我发现和我字体类似人实在太多。不爱和别人一样。其实,在某种程度上,是喜欢上了同桌的字体。她写的字和她的人一样,就是这样的一种感觉。原来在通过手书写出的文字中也能找到某种异样的感觉;而且,除了你很难会有其他人知道。</p>\n<p>有时候,改变是痛苦的,尤其是改变叫"习惯"的那东西;有时候,改变只是谋求换一种感觉。有同学说过,我写作文的速度比他抄的速度还快;只为完成作业的他,怎么会懂?我只是在追求那种写字的感觉。其实,字无须写的有多么好,你要能让你想给她看的人能识别就好;可是,那种写字的感觉,那种心情只有你自己才知道。君不知,能读懂你写字心态的人真不多。</p>\n<p>步入大学后,学上计算机后的我,打字速度一直在进步。从刚开始依赖于 word 文档,到现在整天在 HTML 和 CSS 中摸索,以创造更美的视觉效果。与写字的感觉渐行渐远,近乎忘却。</p>\n<p>老家的枣树每年都会结很多枣,回老家打枣几乎成为回老家唯一的理由。曾经的玩伴,现在的你们都在做些什么;很遗憾的是,我已经忘记了你们那些亲切的名字;颇具讽刺的是,那些和我打过架的,我都还记得;或许,打与被打才会留下痕迹。</p>\n<p>曾听离校学长说过,成功的大学经历,是在你离开的时候和你进来的时候心态是一样的;如果不一样,其实,你已学会了成长,喝下了大学的毒。</p>\n<p>曾经的好友埋怨过我,"不是说好的吗你以后要做一名作家?"确实,其实现在学习计算机的我依旧未变。以前是用文字倾诉心情,而如今,用代码传递意外。只是观众少了许多,抑或是对观众的要求更高了。</p>\n<p>有多少人还能记得写字的感觉呢?曾经的朋友是说过,我背叛了她,说好的,你未来是要做作家的!其实,她不懂,敲代码与构思文字是一样的,只是换了一种方式宣泄情绪。永远不会懂。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//i.loli.net/2019/11/13/Qaq9yVK28W1wtZB.png\" alt=\"銀杏碎葉\" />\n <figcaption>銀杏碎葉・2014秋,拍摄于北京奥森公园</figcaption>\n</figure>\n","site":{"data":{}},"excerpt":"","more":"<p>以前很在意自己写字的感觉,很惬意那意外的享受。</p>\n<p>最近买了一款比较昂贵的钢笔,不为别的,仅仅是钢笔的外观很好看,吸引于我。拿到笔后,放进墨汁,手感远没有想象中的好;抑或是久未触笔的我,已经没了那凌驾于纸间的自在。</p>\n<p>记得那个忘不了的季节,在书店里,什么奇怪的书籍都不在意,只是在漫漫的字帖中挖掘心仪的那些字体。拿着奇奇怪怪的字帖,一笔一笔地,享受着满足的成就感。使用的钢笔只是一种垃圾货,却依旧当作宝;忍不得别人的触碰,倍加爱护。那时的我知道,要写好字,就的要有一支完美的笔。仅此而已。</p>\n<p>意料之中的故事,我最初的审美观确是让人无法理解的"草书"。无论怎样,我随时向我身边的人炫耀着或许只有自己能看明白的字体。最终,启蒙老师无法忍受了。可是我依旧我行我素。我说,你们不懂;他说,不是不懂,而是太懂。</p>\n<p>后来,后来就没有了。我忘记了最后一次和那位老师是如何交流的;离开学校,也就离开了他;更加遗憾难过的是,曾经待了七年的学校在生源不足的情况下,关闭了。曾经,离开的时候,心里暗暗说过,等我以后,在某一日,我会回来让学校变得更好。最近一次去学校的时候,其实是我酿造的意外,教学楼什么的都没变,只是操场上布满了杂草。曾经,和别人打过架的操场;曾经,在这里被授予过骄傲小红花的操场;曾经,在这里排好队后回家的操场。你最大的失败,是无法改变;一件事,你不能改变结局,那就是一件无意义的事;即使,有时你能猜到结局。</p>\n<blockquote>\n<p>"你所创造的是一项伟大的艺术品,而非一堆堆垃圾。"</p>\n</blockquote>\n<p>不知道,这是谁说过的话;不知晓,抑或是我在某本书看到的。不知如何,我在父亲的手里获得一支我很满意的钢笔。我是不在乎细节的,那只钢笔陪伴了我许久,直至丢失的时候,我沉寂了许久。我开始告别以前的潦草,只是因为我发现和我字体类似人实在太多。不爱和别人一样。其实,在某种程度上,是喜欢上了同桌的字体。她写的字和她的人一样,就是这样的一种感觉。原来在通过手书写出的文字中也能找到某种异样的感觉;而且,除了你很难会有其他人知道。</p>\n<p>有时候,改变是痛苦的,尤其是改变叫"习惯"的那东西;有时候,改变只是谋求换一种感觉。有同学说过,我写作文的速度比他抄的速度还快;只为完成作业的他,怎么会懂?我只是在追求那种写字的感觉。其实,字无须写的有多么好,你要能让你想给她看的人能识别就好;可是,那种写字的感觉,那种心情只有你自己才知道。君不知,能读懂你写字心态的人真不多。</p>\n<p>步入大学后,学上计算机后的我,打字速度一直在进步。从刚开始依赖于 word 文档,到现在整天在 HTML 和 CSS 中摸索,以创造更美的视觉效果。与写字的感觉渐行渐远,近乎忘却。</p>\n<p>老家的枣树每年都会结很多枣,回老家打枣几乎成为回老家唯一的理由。曾经的玩伴,现在的你们都在做些什么;很遗憾的是,我已经忘记了你们那些亲切的名字;颇具讽刺的是,那些和我打过架的,我都还记得;或许,打与被打才会留下痕迹。</p>\n<p>曾听离校学长说过,成功的大学经历,是在你离开的时候和你进来的时候心态是一样的;如果不一样,其实,你已学会了成长,喝下了大学的毒。</p>\n<p>曾经的好友埋怨过我,"不是说好的吗你以后要做一名作家?"确实,其实现在学习计算机的我依旧未变。以前是用文字倾诉心情,而如今,用代码传递意外。只是观众少了许多,抑或是对观众的要求更高了。</p>\n<p>有多少人还能记得写字的感觉呢?曾经的朋友是说过,我背叛了她,说好的,你未来是要做作家的!其实,她不懂,敲代码与构思文字是一样的,只是换了一种方式宣泄情绪。永远不会懂。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//i.loli.net/2019/11/13/Qaq9yVK28W1wtZB.png\" alt=\"銀杏碎葉\" />\n <figcaption>銀杏碎葉・2014秋,拍摄于北京奥森公园</figcaption>\n</figure>\n"},{"layout":"layout/post","title":"VR 及 3D 技术在 Web 端架构设计与实践","date":"2021-07-18T15:30:00.000Z","comments":1,"_content":"\n> 本文基于 2021 年 GMTC 全球大前端技术大会\"移动技术新趋势\"专题下主题分享[《VR 及 3D 技术在 Web 端架构设计与实践》](//gmtc.infoq.cn/2021/beijing/presentation/3531)整理而来。内容与当日分享基本无异,仅以文字的形式重新整理一遍。\n\n<style type=\"text/css\">\narticle .ref {display: flex;line-height: 1;border: none;border-radius: 4px;padding: 12px;margin: 12px 0 0;background-color: rgb(239 236 236);border-bottom:none;font-family: Roboto, -apple-system, BlinkMacSystemFont, sans-serif;}article .ref:hover {background-color: rgb(228 223 223);}.ref-img {width: 56px;height: 56px;}.ref-content {padding: 0 0 0 12px;flex: 1;flex-direction: column;display: flex;min-width: 10px;}.ref-title, .ref-subTitle, .ref-link {overflow: hidden;white-space: nowrap;text-overflow: ellipsis;}.ref-title {font-size: 16px;font-weight: 500;line-height: 20px;color: rgb(31, 34, 37);}.ref-subTitle {line-height: 19px;}.ref-link {line-height: 17px;color: rgb(161, 162, 163);}\n.fancyboxflex .fancybox {flex: 1;}\n</style>\n<a class=\"ref\" href=\"//gmtc.infoq.cn/2021/beijing/presentation/3531\" target=\"_blank\">\n <img class=\"ref-img\" src=\"//solome.js.org/static/gmtc-vr3d/5c7caa3b1c540.jpeg\">\n <div class=\"ref-content\">\n <div class=\"ref-title\">GMTC_全球大前端技术大会-InfoQ</div>\n <div class=\"ref-subTitle\">\"GMTC是由极客邦科技和InfoQ中国主办的顶级技术盛会,关注移动、前端、AI应用等多个技术领域,促进全球技术交流,推动国内技术升级。GMTC为期4天,包括两天的会议和两天的培训课,主要面向各行业对移动开发、前端、AI技术感兴趣的中高端技术人员,大会聚焦前沿技术及实践经验,旨在帮助参会者了解移动开发&前端领域最新的技术趋势与最佳实践。\"</div>\n <div class=\"ref-link\">https://gmtc.infoq.cn/2021/beijing/presentation/3531</div>\n </div>\n</a>\n\nVR 看房是 VR 及 3D 技术落地的场景之一,其特点是通过手机终端就能让人真正的置身其中,用自己直觉的空间感去感受整个房屋特征。本次分享将介绍贝壳如视前端团队是如何基于 VR 3D 模型进行前端架构设计的。除此之外,还将分享我们团队是如何基于 VR 看房能力探索新的业务形式以及面临的技术挑战。\n\n## 基于 VR 3D 模型前端架构设计\n\n在讲前端架构设计之前,先详细介绍下看房场景下的 VR 3D 模型的组成及形态。\n\n### 看房 VR 3D 模型的组成及形态\n\n房源的 VR 3D 模型的形态有多种,但在用户层面直观感受到的主要有三个形态:3D 模型形态、点位全景形态及 VR 眼镜视角形态。下面对这三个形态做详细介绍。\n\n#### 3D 模型\n\n首先,我们简单思考一下三维模型是如何在二维平面抽象建模的?目前主流的三维模型抽象建模是基于多边形网格(Polygon Mesh),如图一所示。整体感知就是多边形面片愈多(面片密度)还原的三维立体效果愈真实。最精简的多边形自然是三角形(大部分场景下说的面片即三角面片),三维物体的每个细节可以通过三角面片的顶点、边及面等几何数学概念来描述。微观上来看,基于面片建模的三维模型本质上都是密度及其复杂的几何体。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic1.png\" alt=\"多边形网格模拟立体效果\" />\n <figcaption>图一:多边形网格模拟立体效果</figcaption>\n</figure>\n\n因此,依赖一些专业 3D 扫描仪(比如如视自研的黎曼、伽罗华等扫描仪)或全景相机等设备采集数据后,再通过算法加工可以获取这些描述三维立体结构的三角面片数据。前端再利用 WebGL/Three.js 等技术将其渲染至浏览器上,此时我们能得到房源的三维立体轮廓,效果如图二(左)所示的网格模型。当然,图二(右)才是我们期望的效果,仅仅有三维\"骨架\"轮廓是不够的,我们需要在此基础上贴一层\"皮肤\",而这层\"皮肤\"则是通过 UV 纹理贴图添加上的。\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2left.gif\" /></div>\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2right.gif\" /></div>\n </div>\n <figcaption>图二:三角面片描述的三维效果</figcaption>\n</figure>\n\n对于三维模型有两个比较重要的坐标系统,一个是顶点的位置`(x,y,z)`坐标,另一个则是 UV 坐标。什么是 UV 呢?简言之,就是二维平面贴图映射到三维模型表面的依据。比如典型的 UV 贴图效果如图三所示,刚刚前文提到三维结构是通过顶点、边及面组成的三角面片组成的,这个三角面是二维的,通过一些数据依赖映射关系从 UV 贴图中抠出一个相同边、面的三角形贴到三角面片上。所以,此处的 UV 即指定义了二维平面图片每个点的位置与三维结构三角面片位置的映射关系信息。作为前端工程师,这个跟前端雪碧图(Sprite)概念将多个图标合并成一张图的原理是一致的。\n\n<figure>\n <img style=\"width: 35%\" src=\"//solome.js.org/static/gmtc-vr3d/pic3.png\" alt=\"房源UV贴图\" />\n <figcaption>图三:房源UV贴图</figcaption>\n</figure>\n\n至此,基于三角面片和 UV 贴图数据我们成功渲染出了房源的 3D 模型。当然,出于性能考虑我们的三角面片密度不是特别高的,纯粹依靠 3D 模型在终端设备(iOS\\Android 等)还原房源的真实细节现阶段并不现实。三角面片少,数据量低,内存占用低,我们可以通过 3D 模型还原房源的整体结构。至于细节,则通过点位立方体全景的方式去实现。\n\n#### 点位全景\n\n前文提到房源的整体结构通过 3D 模型体现,至于细节则通过全景的形式来表现。我们会在房源选择多个合适的点位拍摄全景图片,然后以立方体全景的方式渲染以实现 720 º 环顾的效果,如图四(左)所示。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4left.gif\" alt=\"全景效果\" /></div>\n <div style=\"flex: 14;padding: 0 0 0 40px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4right.png\" alt=\"全景贴图展开\" /></div>\n </div>\n <figcaption>图四:立方体全景效果及其展开</figcaption>\n</figure>\n\n全景的实现是比较成熟的技术,主流的实现方式有立方体全景和球型全景。两种方式各有优缺点,由于立方体全景二次加工成本低如视目前以立方体全景技术实现为主。立方体全景的原理是渲染一个立方体盒子,给其上、下、前、后、左和右六个面各贴上一张图。需要注意的是,这六张图从中选择连续的四张图拼接在一起是一张连贯的全景图,如图四(右)所示 T 字形立方体贴图展开。此时,当人眼放置在立方体中心点观望四周是连贯的全景效果。\n\n全景的效果完全依赖贴图的清晰度,所以我们可以拍摄高清 2048 分辨率的全景图片去体现房源某个位置的细节信息。这也是看房 VR 3D 模型的第二个核心形态点位全景形态。\n\n#### VR 眼镜全景\n\n前文提到的 3D 模型和点位全景形态都是基于二维显示屏展现的(裸眼体验),如果想让用户具备身临其境的感觉往往需要依赖 VR 眼镜设备。针对这类设备我们需要适配[WebXR Device API](//www.w3.org/TR/webxr/),我们现阶段的适配策略是渲染两个相同的点位立方体全景,分别供左右眼感知。最终适配的效果如图五所示。\n\n<figure>\n <div style=\"flex: 1;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic5left.gif\" alt=\"眼镜全景\" /></div>\n <figcaption>图五:VR 眼镜全景</figcaption>\n</figure>\n\n限于大部分用户的设备还是 iOS\\Android,目前的裸眼 VR 3D 体验是主流。随着硬件设备的推广,等到 VR 眼镜走向普通用户时,这种更具身临其境的体验会慢慢更多用户接触到。\n\n当然,除了本文提到 3D 模型形态、点位全景和 VR 眼镜全景三种形态之外,我们内部还有多种其他形态,如模型垂直视角、深度图渲染的全景视角等形态,但是偏技术领域且与普通用户感知不深,此处不详细介绍了。\n\n最后,基于这三种形态外加一个房源的二维户型图就组成了我们看房 VR 3D 模型的核心结构,在此基础不断完善各种交互(比如形态间切换补间 Tween 动画)、产品功能逐步演变成大家所熟悉的贝壳如视 VR 看房。\n\n> 演讲问答环节及后续的反馈情况来看,大家对分享提到的形态间切换的 Tween 动画实现比较感兴趣,且部分同行表示自己实现的效果达不到如视的移动真实感。此处细节较多,准备后续单独出文章分享,本文暂不花费篇幅详细介绍。\n\n### 前端架构分层设计\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic6.png\" alt=\"前端架构分层设计\" />\n <figcaption>图六:前端架构分层设计</figcaption>\n</figure>\n\n前文提到房源的 VR 3D 模型的组成及三个核心形态,我们实现了通过 3D 技术真实还原房源信息。经过多轮的产品需求迭代,我们在 VR 3D 模型的基础上不断地完善整个前端的架构分层设计。现阶段,整个 VR 用户端前端设计中我们抽象了三层:Web 服务层、前端数据层和 View 层。\n\n我们将 View 层划分成四个方向进行抽象,第一个方向是纯 DOM 层的,比如首屏内容、控制面板、信息面板等,这层我们通常以 React/Vue 组件进行抽象服用。第二个方向是基于 Canvas/WebGL 渲染的三维视图,其功能即前文提到的房源 VR 3D 模型交互。第三个方向是我们维护的 3D 插件生态,以 VR 3D 模型为基础且以插件的形式派生出新的交互、能力(比如,模型中的指南针、电视视频等均以插件的形式集成)。最后一个方向是协议层抽象,我们 VR 是通过 Web 前端技术渲染实现的,以 WebView 作为容器集成在终端 App 里面,通过 jsBridge 的方式实现双向通信。为了保障业务代码的统一性,我们将第三方依赖(jsBridge/RTC/WebSocket 等)进行一层协议抽象,以达到面向协议开发以抹平不同终端差异性的目的。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic7.png\" alt=\"数据序列帧抽象\" />\n <figcaption>图七:数据序列帧抽象</figcaption>\n</figure>\n\n第二层是数据层的抽象。此处的数据并不是面向后端服务的数据层,而是前端 UI 交互的数据层抽象。我们将 UI 交互的状态以全局帧数据的形式抽象出来,当 UI 发生变化则同步至帧数据;当然,如果帧数据被发生改动(修改帧数据对象)则也会驱动 UI 发生相同变化。这个过程通过 JavaSciprt 中 Proxy 拦截数据对象实现的,如图七。换言之,UI 交互能产生新的帧数据,通过帧数据也能还原对应的 UI 状态。至于,为什么要花费大量精力做这个工作后文讲解业务部分时会有详细介绍。\n\n第三层 Web 层有两个方向的核心服务,其中基于 Node.js/Go 实现的 HTTP 服务主要提供 VR 页面的 HTML\"壳子\"和首屏数据,而基于 WebSocket 服务的全双工数据通道则保障了 VR 体验过程与后台服务的实时通讯。WebSocket 长链接技术有传统 HTTP 方式无可比拟的优势(协议私有、实时性高、性能优异等),对我们业务的智能化、性能体验提升等无可替代,下文描述业务探索和性能体验部分大家会有更深切的感知。\n\n贝壳如视用户端的前端设计大致如此,我们大部分核心业务如 VR 语言导览、VR 实时带看和 AR 讲房等都是基于此设计研发的。\n\n### 基于 3D 模型与传统 DOM 开发的差异性对比\n\n作为一名工作频繁接触 3D 相关技术的研发工程师,经常被咨询基于 3D 模型研发与传统 DOM 开发的区别。与传统前端开发差异性是存在的,但是适应如下三点基本就迈入前端 3D 开发的门槛。\n\n#### 三维坐标系 vs DOM 树\n\n前端 DOM 树布局是基于 CSS 盒子模型和 Flex 布局,页面大部分布局都是基于此实现的,此外还有圣杯、双飞翼等经典布局体系。在二维层面依托强大的 CSS,前端布局是随心所欲的。但是放在三维空间,我们大部分时间都在跟坐标系及坐标系间切换打交道。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic8.png\" alt=\"三维建模坐标体系\" />\n <figcaption>图八:三维建模坐标体系</figcaption>\n</figure>\n\n三维研发的首个门槛就是跟各种坐标系打交道,比如三维物体本身的坐标系(一般称呼为本地坐标系),一个三维空间会存在多个三维物体,如何放置这些三维物体则需要一个三维世界坐标系来定位。此外,三维空间的三维物体通常都是静止的,其移动、旋转等操作都是控制相机的移动来实现的(当然,相机也是一种特殊的三维物体),如图八所示。然而,我们终端设备的屏幕是二维的,相机作为一个\"眼睛\"将三维物体投影到二维屏幕上又涉及到平面坐标系、齐次坐标系等等。所以,如何理清这些坐标系的概念和坐标系间的相互转换是 3D 研发的首个门槛,搞清这些在日后的研发中就能做到\"游刃有余\"。\n\n#### 面向异步 Hooks 事件\n\n在处理三维模型行为交互体验时与传统前端还有个很明显的差异就是面临的异步细节要多得多。在 DOM 层面前端开发时,我们接触的异步事件主要集中在点击、触摸、滚动和 Ajax 异步请求等。但是在三维交互中,除此之外我们还频繁接触放大缩小、拖拽位移、模式切换等各类异步行为。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9left.gif\" alt=\"全景走点效果\" /></div>\n <div style=\"flex: 3;padding: 0 0 0 10px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9right.png\" alt=\"涉及异步hooks事件\" /></div>\n </div>\n <figcaption>图九:点位全景切换走点</figcaption>\n</figure>\n\n在如视内部的底层渲染引擎中,我们维护了比较完善的异步 Hooks 事件集来应对各种场景的交互行为。比如,如图九(左)效果是我们常见的 VR 房源点位全景交互走点移动,整个过程触发了九个异步事件回调,如图九(右)所列。这些回调将整个过程的细节全部暴露出来,方便研发人员更精准地把控体验。一般的终端工程师很难体验这种交互层面细维度精准把控的开发体验,初次接触需要适应。\n\n#### 碰撞检测\n\n最后一个比较明显的差异性是三维空间里面的碰撞监测。\n\n<figure>\n <img style=\"width: 50%;min-width: 200px;\" src=\"//solome.js.org/static/gmtc-vr3d/pic10.gif\" alt=\"物体间遮挡与重叠\" />\n <figcaption>图十:物体间遮挡与重叠</figcaption>\n</figure>\n\n如图十所示,在三维空间中摆置新物体难免会涉及遮盖、重叠的情况。在实际开发中,我们尽量规避这种现象的发生。碰撞监测常规的做法是针对物体创建一个规则的立体几何外形将其包围然后分析是否有重叠的部分;还有种思路是建立一条射线,获取此射线与两个物体间的焦点然后分析是否重合。\n碰撞监测在不同的场景一般会采用合适的方式,对于移动的物体,有时候我们还需要在建模体系中添加物理引擎的支持。碰撞检测在不同的业务场景下,检测的策略是不同的,这个比较考验研发对整个三维空间的理解能力,本文就不展开更细节的内容了。\n\n## 新型业务场景探索与实践\n\n前文涉及的都是偏技术领域的,下面向大家分享下在已有的技术储备下,如视是如何在业务上做的一些探索与实践的。\n\n### 三维空间分析计算与二次加工\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic11.gif\" alt=\"物体(家具)识别\" />\n <figcaption>图十一:物体(家具)识别</figcaption>\n</figure>\n\n三维模型是来源于现实真实的房源(通过专业设备拍摄及算法分析获取),我们可以对三维模型进行分析并将里面的家具物体识别出来(如图十一所示)。识别出这些物体后我们就能做些有趣的事情了,比如识别出显示器或电视,可以在此处添加一个视频播放广告或节目来营造更加真实的 3D 场景,效果如图十二(左)。识别平滑地面,我们可以放置一个扫地机器人或 3D 宝箱来做些营销活动等等,效果如图十二(中)、(右)。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12left.gif\" alt=\"电视视频\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12center.gif\" alt=\"扫地机器人\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;transform: scale(0.9153225806451613);transform-origin: left;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12right.gif\" alt=\"宝箱营销\" /></div>\n </div>\n <figcaption>图十二:根据物体识别添加动态内容</figcaption>\n</figure>\n\n除了空间内的物体识别之外,户型图也是我们二次加工的重点方向。比如,我们将二手房源里面家具及装修物体全部清理掉,然后就得到一个及其\"纯净\"的白模模型;在基于原有的户型结构重新规划将一个两室一厅的房源改造成一个三室一厅的房源,然后再重新加工装修风格和摆置家居物体等。\n\n整个过程,如图十三(左)所示,经历了从真实复杂的普通房源到简洁的白模再到复杂的新装修家居风格过程,给潜在的购房用户提前示例这套房源的改造空间。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 7;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13left.png\" alt=\"加工过程\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13right.gif\" alt=\"一键切换\" /></div>\n </div>\n <figcaption>图十三:真实房源的二次加工</figcaption>\n</figure>\n\n此外,我们在技术体验上也做了些突破,在终端层面实现真实房源与设计房源一键切换和同屏对比的交互体验,最终效果如图十三(右)所示。\n\n### VR 实时带看:同屏连线,高效看房\n\n另外一个业务场景探索则是线上 VR 实时带看能力的落地。首先,解释下为什么要往这个方向探索?大家有过买房或租房体验的都知道,大部分场景都是经纪人开车载着你去实地看房,一天下来也就看几套房源可能还要爬楼梯、等红绿灯或被太阳曝晒等意外情况。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 4;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14left.png\" alt=\"3D 交互与二维交互对比\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right1.gif\" alt=\"VR 同屏1\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right2.gif\" alt=\"VR 同屏2\" /></div>\n </div>\n <figcaption>图十四:3D 交互与二维交互对比及 VR 同屏</figcaption>\n</figure>\n\n尽管 VR 房源虽然还原了房源的真实场景,但是三维空间交互还是比较复杂的,需要用户去探索细节。如图十四(左)是经典的信息流布局:搜索 ➙ 导航 ➙ 推荐 ➙ 筛选 ➙ 列表,这是二维最高效的信息展示布局,国内绝大部分提供数据服务的 App(电商京东、餐饮美团、房产贝壳等)均是这类布局。\n\n但是三维空间交互就没有这么明确了,全景只能查看当前点位且全景游走大部分用户并不知晓。此外,诸如房源的小区信息和附近学校、医院等信息也无法在 VR 3D 模型中明确体现。因此,我们实现了由用户无目的的在 VR 3D 模型中漫游、探索信息转向专业由经纪人带领画面同步、实时语言讲解。\n\n前文提到我们将前端所有的交互以序列帧数据的形式进行了抽象,用户交互会产生帧数据然后通过 WebSocket 将生成的帧数据同步给另外一个用户来驱动另外一个用户画面的更新。语音的话目前 RTC 技术比较成熟,我们落地即可,效果如图十四(右)所示。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic15.png\" alt=\"终端App与微信小程序VR 实时带看通道链路\" />\n <figcaption>图十五:终端App与微信小程序VR 实时带看通道链路</figcaption>\n</figure>\n\n除了端与端 VR 带看之外,我们还实现终端 App(iOS/Android)与微信小程序的 VR 实时语音带看的业务能力,整个链路通道如图十五所示。\n\n线上 VR 实时带看能力在 2018 年底我们就已经初步实现落地,由于 2020 年新冠疫情影响造成大批潜在购房用户和经纪人居家隔离,线上 VR 实时带看目前已经成为了看房业务的核心场景。\n\n### VR 智能讲房:智能解说,身临其境\n\n前面提到 VR 带看是通过专业的经纪人陪同去了解房源解决 VR 3D 看房获取信息的方式不高效问题。但这个业务场景也存在些许缺陷:\n\n- 人力成本:经纪人不一定能及时响应,比如深夜休息时段。\n- 专业水平:不能保障经纪人对所有的房源都了解,又诸如方言等沟通效率。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic16.gif\" alt=\"社交恐惧症\" />\n <figcaption>图十六:“社交恐惧症”:客户不愿跟陌生人沟通</figcaption>\n</figure>\n\n- 顾客“社交恐惧症”:不是人人都愿意跟陌生人沟通等。\n\n鉴于此,我们尝试把 VR 3D 交互做得更智能些。怎么做才更智能呢?首先,我们得不完全依赖真实的经纪人。我们将真实的经纪人形象和音色采集出来然后通过视频拼接和语言 TTS 服务来抽象出一个虚拟经纪人,并将此虚拟经纪人形象搬到用户的终端屏幕上,如图十七所示。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic17.gif\" alt=\"虚拟数字经纪人\" />\n <figcaption>图十七:虚拟数字经纪人</figcaption>\n</figure>\n\n有了虚拟的经纪人,那么该讲解什么样的内容呢?VR 带看语音来自于经纪人,画面行为帧数据也来源于经纪人行为。此时,就需要通过算法层面去合成讲稿并生产对应的音频和序列帧数据。整体的架构如图十八所示,前端所需要支持的就是定义画面行为的序列帧数据格式规范,由 AI 团队的剧本服务和 NLG 服务去计算 LRC 文本讲稿和行为序列。然后,通过主控服务生成带讲稿音频虚拟经纪人视频并附带行为序列帧数据给前端\"翻译\"。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic18.png\" alt=\"AR 讲房架构\" />\n <figcaption>图十八:AR 讲房架构</figcaption>\n</figure>\n\n因为涉及的点过多,更多的细节本文就不再详细讲解了。大家可以扫描图十九的二维码或访问 [珠江罗马嘉园东区 2 室 1 厅](//open.realsee.com/ke/15XKMYpVwOw3R7j8/BoZqQK8KmaAtncxhvTYre9ztvW9D50zg/?v3=1) 这套房源进行体验。总之,由于 WebSocket 双工实时性和前端序列帧数据抽象,VR 的整体体验变得更加智能化。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19left.png\" alt=\"体验二维码\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19right.png\" alt=\"入口位置\" /></div>\n </div>\n <figcaption>图十九:AR 讲房体验二维码</figcaption>\n</figure>\n\n## 面临的性能挑战及应对方案\n\n在过去三年的 VR 看房及衍生业务研发中我们主要面临的性能瓶颈有两个:加载耗时和内存溢出。\n\n### 加载耗时\n\n在 2019 年 8 月份前,贝壳如视 VR 首屏加载平均耗时 7.6s,截至 2021 年 7 月份已经降至 1.92s,正常网络情况下用户基本无需等待过多时间去体验 VR 房源。如此巨大的提升我们究竟做了些什么呢?首先我们先分析之前慢的原因,然后\"对症下药\"。而且首屏的性能提升也不是一蹴而就的事情,我们内部成立了个性能体验专项虚拟团队持续了近一年才达到最终 1.92s 的效果。\n\n问题出在哪儿呢?主要在三个方面:\n\n#### 密集的 HTTP 请求\n\n前文提到 VR 3D 模型依赖大量的模型 UV 贴图和全景图片;除此之外,还有大量的地图、讲房音视频等资源。在浏览器的限制下同个域下的 CDN 请求限制在 3~6 个(不同浏览器会有差异)。大量的网络请求只能排队等待。\n\n#### 实时计算\n\n前端存在大量的实时计算,比如 3D 模型文件的解压缩、户型图数据解析、三维空间分析及碰撞监测等。由于 JavaScript 的单进程,这些计算依赖也阻塞一些核心逻辑。\n\n#### 模块渲染加载策略不合理\n\n由于 VR 开发初期考虑不周全,我们的异步渲染加载策略设计并不合理,优先级策略划分错乱。\n\n分析原因后,优化策略就很明确了。针对密集的 HTTP 请求我们先添加更多 CDN 域名支持,保障同时刻的请求限制在五个以内并增加 HTTP2 协议支持。实时计算带来的耗时采取的策略是充分利用缓存(离线计算缓存、浏览器缓存以及服务端计算缓存等);同时,我们对模块渲染加载策略进行了重新设计,每个模块都规划好权重,按照权重来加载。此外,部分非核心交互则由用户触发后再加载渲染。由于历史包袱过重,真个过程持续了近一年,最终有了 7.6s 到 2.55s 的首屏加载的性能提升,过程如图二十(左)所示。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 3;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20left.png\" alt=\"耗时变化\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20right.gif\" alt=\"加载效果\" /></div>\n </div>\n <figcaption>图二十:VR 首屏性能提升过程</figcaption>\n</figure>\n\n除上文提到的优化之外,我们还充分挖掘了部分客户端的能力。第一个能力是**客户端 HTTP 请求拦截代理和缓存**,通常情况下 WebView 缓存池\"阈值\"很低,而客户端缓存池则大得多;此外,分析对比来看客户端的 HTTP 请求效率要比 WebView 的 HTTP 请求高很多。支持 HTTP 请求代理和缓存之后,整个加载耗时降低了近 500ms。\n\n另外一个核心能力则是增加了**客户端首屏渲染**:即进入 VR 页面前客户端提前预载好首屏内容,在加载阶段展示客户端内容,等前端完成首屏渲染之后再换成前端的渲染效果。整个过程是无缝的,用户甚至感知不到加载过程,最终的效果如图二十(右)所示。\n\n### 内存溢出\n\n加载耗时现阶段已经取得比较好的效果,我们目前遭遇的最大的瓶颈是内存溢出。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic21.png\" alt=\"VR 内存占用\" />\n <figcaption>图二十一:VR 内存占用</figcaption>\n</figure>\n\n在前文首屏优化中提到我们耗费大量的时间完善了模块加载渲染策略,因此在 VR 交互过程中,随着各个模块不断完成渲染,内存占用是逐步递增的,如图二十一(左)所示。在图二十一(右)扇形图中也列举了不同模块的内存占用情况。目前,iOS 设备的 WebView 内存崩溃的阈值大约在 1.5G 左右,Android 设备则不同机型阈值不完全一致,高端 Android 设备普遍比 iOS 设备高很多,但低端机阈值远低于 1.5G 内存。\n\n规避内存溢出问题我们从两个方向入手:\n\n#### 增加内存池\n\n目前我们测试过 iOS/Android 设备各类 WebView 控件,除了实现 WebView 独立进程之外并没有找到突破 WebView 内存限制的方式。这个属于 WebView 容器瓶颈。\n\n#### 降低内存占用\n\n我们做了些突破,比如按需渲染,非可视区域销毁模块等等,但仅仅降低了崩溃率,成效并不明显。\n\n而且,随着业务的不断迭代,VR 能力愈来愈丰富,内存占用还在不断提升。依赖 WebView+WebGL+jsBridge 技术栈落地的 VR 体验现阶段有很明显的局限性,虽然纯原生技术栈已经提上日程但短期来看还是很难落地的。为了弱化内存溢出带来的影响,我们目前采取的策略是根据用户的使用场景以动态降级的方式给予用户最合适的交互体验。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic22.png\" alt=\"VR 性能瓶颈影响因素鱼骨图\" />\n <figcaption>图二十二:VR 性能瓶颈影响因素鱼骨图</figcaption>\n</figure>\n\n性能优化的本质是渐进增强和优雅降级,把握每个细节把自己该做的部分做好一般都会有比较好的性能表现。我们系统分析了造成性能瓶颈各个因素,如图二十二所示。事实上,我们很难做些突破然后彻底解决内存问题,只能降级保障体验。\n\n如何做到更\"智能\"地渐进增强和优雅降级?首先需要的是前端支持模块的\"热插拔\"能力,即能动态的销毁某个模块以将内存空间给其他模块使用。此外,我们维护一个关于内存瓶颈的数据仓库,依托 WebSocket 的双工能力,VR 交互时会收集用户的终端设备信息及部分 VR 用户行为,并在实时分析该用户的终端的最大承受能力,推送给前端再动态地加载或卸载前端模块,从而达到加强体验或降级的效果。\n\n## 总结\n\n前面给大家讲述了贝壳如视前端团队如何基于 VR 及 3D 技术在 Web 领域架构设计,并分享了在这个领域上的一些业务探索、实践及应对性能瓶颈的具体措施。本次的演讲的专题是\"移动技术新趋势\",最后站在技术的角度上做如下四个方面的经验(或趋势)总结来结束本次的演讲内容吧。\n\n### 可玩性\n\n三维领域研发比传统基于 DOM 前端研发有趣得多,比如团队就有产品说过三维空间二次加工装修设计是更高阶的\"乐高\"式游戏,欢迎大家加入这个领域。\n\n### 序列帧抽象及数据驱动\n\n过往的前端交互都是用户主动触发的,但是在 3D 方向的交互模型更需要自动播放,提高信息获取的方式。前端数据层序列帧抽象,支持数据驱动、序列化和反序列化将是不可或缺的一环。\n\n### \"热插拔\"\n\n3D 领域开发内存占用是远大于传统前端页面的,尤其在终端设备 WebView 容器下内存限制更明显。模块、组件及插件等封装都需要支持\"热插拔\",从而做到动态加强体验或降级的效果。\n\n### WebSocket\n\n我们已经逐步在抛弃主动式 Ajax,数据的实时性和智能化都依赖 WebSocket 的双工能力。目前,WebSocket 服务已经是核心基础建设。\n","source":"_posts/gmtc-vr3d.md","raw":"---\nlayout: layout/post\ntitle: 'VR 及 3D 技术在 Web 端架构设计与实践'\ndate: 2021-07-18 23:30:00 +0800\ncomments: true\ncategories: 技术分享\n---\n\n> 本文基于 2021 年 GMTC 全球大前端技术大会\"移动技术新趋势\"专题下主题分享[《VR 及 3D 技术在 Web 端架构设计与实践》](//gmtc.infoq.cn/2021/beijing/presentation/3531)整理而来。内容与当日分享基本无异,仅以文字的形式重新整理一遍。\n\n<style type=\"text/css\">\narticle .ref {display: flex;line-height: 1;border: none;border-radius: 4px;padding: 12px;margin: 12px 0 0;background-color: rgb(239 236 236);border-bottom:none;font-family: Roboto, -apple-system, BlinkMacSystemFont, sans-serif;}article .ref:hover {background-color: rgb(228 223 223);}.ref-img {width: 56px;height: 56px;}.ref-content {padding: 0 0 0 12px;flex: 1;flex-direction: column;display: flex;min-width: 10px;}.ref-title, .ref-subTitle, .ref-link {overflow: hidden;white-space: nowrap;text-overflow: ellipsis;}.ref-title {font-size: 16px;font-weight: 500;line-height: 20px;color: rgb(31, 34, 37);}.ref-subTitle {line-height: 19px;}.ref-link {line-height: 17px;color: rgb(161, 162, 163);}\n.fancyboxflex .fancybox {flex: 1;}\n</style>\n<a class=\"ref\" href=\"//gmtc.infoq.cn/2021/beijing/presentation/3531\" target=\"_blank\">\n <img class=\"ref-img\" src=\"//solome.js.org/static/gmtc-vr3d/5c7caa3b1c540.jpeg\">\n <div class=\"ref-content\">\n <div class=\"ref-title\">GMTC_全球大前端技术大会-InfoQ</div>\n <div class=\"ref-subTitle\">\"GMTC是由极客邦科技和InfoQ中国主办的顶级技术盛会,关注移动、前端、AI应用等多个技术领域,促进全球技术交流,推动国内技术升级。GMTC为期4天,包括两天的会议和两天的培训课,主要面向各行业对移动开发、前端、AI技术感兴趣的中高端技术人员,大会聚焦前沿技术及实践经验,旨在帮助参会者了解移动开发&前端领域最新的技术趋势与最佳实践。\"</div>\n <div class=\"ref-link\">https://gmtc.infoq.cn/2021/beijing/presentation/3531</div>\n </div>\n</a>\n\nVR 看房是 VR 及 3D 技术落地的场景之一,其特点是通过手机终端就能让人真正的置身其中,用自己直觉的空间感去感受整个房屋特征。本次分享将介绍贝壳如视前端团队是如何基于 VR 3D 模型进行前端架构设计的。除此之外,还将分享我们团队是如何基于 VR 看房能力探索新的业务形式以及面临的技术挑战。\n\n## 基于 VR 3D 模型前端架构设计\n\n在讲前端架构设计之前,先详细介绍下看房场景下的 VR 3D 模型的组成及形态。\n\n### 看房 VR 3D 模型的组成及形态\n\n房源的 VR 3D 模型的形态有多种,但在用户层面直观感受到的主要有三个形态:3D 模型形态、点位全景形态及 VR 眼镜视角形态。下面对这三个形态做详细介绍。\n\n#### 3D 模型\n\n首先,我们简单思考一下三维模型是如何在二维平面抽象建模的?目前主流的三维模型抽象建模是基于多边形网格(Polygon Mesh),如图一所示。整体感知就是多边形面片愈多(面片密度)还原的三维立体效果愈真实。最精简的多边形自然是三角形(大部分场景下说的面片即三角面片),三维物体的每个细节可以通过三角面片的顶点、边及面等几何数学概念来描述。微观上来看,基于面片建模的三维模型本质上都是密度及其复杂的几何体。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic1.png\" alt=\"多边形网格模拟立体效果\" />\n <figcaption>图一:多边形网格模拟立体效果</figcaption>\n</figure>\n\n因此,依赖一些专业 3D 扫描仪(比如如视自研的黎曼、伽罗华等扫描仪)或全景相机等设备采集数据后,再通过算法加工可以获取这些描述三维立体结构的三角面片数据。前端再利用 WebGL/Three.js 等技术将其渲染至浏览器上,此时我们能得到房源的三维立体轮廓,效果如图二(左)所示的网格模型。当然,图二(右)才是我们期望的效果,仅仅有三维\"骨架\"轮廓是不够的,我们需要在此基础上贴一层\"皮肤\",而这层\"皮肤\"则是通过 UV 纹理贴图添加上的。\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2left.gif\" /></div>\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2right.gif\" /></div>\n </div>\n <figcaption>图二:三角面片描述的三维效果</figcaption>\n</figure>\n\n对于三维模型有两个比较重要的坐标系统,一个是顶点的位置`(x,y,z)`坐标,另一个则是 UV 坐标。什么是 UV 呢?简言之,就是二维平面贴图映射到三维模型表面的依据。比如典型的 UV 贴图效果如图三所示,刚刚前文提到三维结构是通过顶点、边及面组成的三角面片组成的,这个三角面是二维的,通过一些数据依赖映射关系从 UV 贴图中抠出一个相同边、面的三角形贴到三角面片上。所以,此处的 UV 即指定义了二维平面图片每个点的位置与三维结构三角面片位置的映射关系信息。作为前端工程师,这个跟前端雪碧图(Sprite)概念将多个图标合并成一张图的原理是一致的。\n\n<figure>\n <img style=\"width: 35%\" src=\"//solome.js.org/static/gmtc-vr3d/pic3.png\" alt=\"房源UV贴图\" />\n <figcaption>图三:房源UV贴图</figcaption>\n</figure>\n\n至此,基于三角面片和 UV 贴图数据我们成功渲染出了房源的 3D 模型。当然,出于性能考虑我们的三角面片密度不是特别高的,纯粹依靠 3D 模型在终端设备(iOS\\Android 等)还原房源的真实细节现阶段并不现实。三角面片少,数据量低,内存占用低,我们可以通过 3D 模型还原房源的整体结构。至于细节,则通过点位立方体全景的方式去实现。\n\n#### 点位全景\n\n前文提到房源的整体结构通过 3D 模型体现,至于细节则通过全景的形式来表现。我们会在房源选择多个合适的点位拍摄全景图片,然后以立方体全景的方式渲染以实现 720 º 环顾的效果,如图四(左)所示。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4left.gif\" alt=\"全景效果\" /></div>\n <div style=\"flex: 14;padding: 0 0 0 40px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4right.png\" alt=\"全景贴图展开\" /></div>\n </div>\n <figcaption>图四:立方体全景效果及其展开</figcaption>\n</figure>\n\n全景的实现是比较成熟的技术,主流的实现方式有立方体全景和球型全景。两种方式各有优缺点,由于立方体全景二次加工成本低如视目前以立方体全景技术实现为主。立方体全景的原理是渲染一个立方体盒子,给其上、下、前、后、左和右六个面各贴上一张图。需要注意的是,这六张图从中选择连续的四张图拼接在一起是一张连贯的全景图,如图四(右)所示 T 字形立方体贴图展开。此时,当人眼放置在立方体中心点观望四周是连贯的全景效果。\n\n全景的效果完全依赖贴图的清晰度,所以我们可以拍摄高清 2048 分辨率的全景图片去体现房源某个位置的细节信息。这也是看房 VR 3D 模型的第二个核心形态点位全景形态。\n\n#### VR 眼镜全景\n\n前文提到的 3D 模型和点位全景形态都是基于二维显示屏展现的(裸眼体验),如果想让用户具备身临其境的感觉往往需要依赖 VR 眼镜设备。针对这类设备我们需要适配[WebXR Device API](//www.w3.org/TR/webxr/),我们现阶段的适配策略是渲染两个相同的点位立方体全景,分别供左右眼感知。最终适配的效果如图五所示。\n\n<figure>\n <div style=\"flex: 1;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic5left.gif\" alt=\"眼镜全景\" /></div>\n <figcaption>图五:VR 眼镜全景</figcaption>\n</figure>\n\n限于大部分用户的设备还是 iOS\\Android,目前的裸眼 VR 3D 体验是主流。随着硬件设备的推广,等到 VR 眼镜走向普通用户时,这种更具身临其境的体验会慢慢更多用户接触到。\n\n当然,除了本文提到 3D 模型形态、点位全景和 VR 眼镜全景三种形态之外,我们内部还有多种其他形态,如模型垂直视角、深度图渲染的全景视角等形态,但是偏技术领域且与普通用户感知不深,此处不详细介绍了。\n\n最后,基于这三种形态外加一个房源的二维户型图就组成了我们看房 VR 3D 模型的核心结构,在此基础不断完善各种交互(比如形态间切换补间 Tween 动画)、产品功能逐步演变成大家所熟悉的贝壳如视 VR 看房。\n\n> 演讲问答环节及后续的反馈情况来看,大家对分享提到的形态间切换的 Tween 动画实现比较感兴趣,且部分同行表示自己实现的效果达不到如视的移动真实感。此处细节较多,准备后续单独出文章分享,本文暂不花费篇幅详细介绍。\n\n### 前端架构分层设计\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic6.png\" alt=\"前端架构分层设计\" />\n <figcaption>图六:前端架构分层设计</figcaption>\n</figure>\n\n前文提到房源的 VR 3D 模型的组成及三个核心形态,我们实现了通过 3D 技术真实还原房源信息。经过多轮的产品需求迭代,我们在 VR 3D 模型的基础上不断地完善整个前端的架构分层设计。现阶段,整个 VR 用户端前端设计中我们抽象了三层:Web 服务层、前端数据层和 View 层。\n\n我们将 View 层划分成四个方向进行抽象,第一个方向是纯 DOM 层的,比如首屏内容、控制面板、信息面板等,这层我们通常以 React/Vue 组件进行抽象服用。第二个方向是基于 Canvas/WebGL 渲染的三维视图,其功能即前文提到的房源 VR 3D 模型交互。第三个方向是我们维护的 3D 插件生态,以 VR 3D 模型为基础且以插件的形式派生出新的交互、能力(比如,模型中的指南针、电视视频等均以插件的形式集成)。最后一个方向是协议层抽象,我们 VR 是通过 Web 前端技术渲染实现的,以 WebView 作为容器集成在终端 App 里面,通过 jsBridge 的方式实现双向通信。为了保障业务代码的统一性,我们将第三方依赖(jsBridge/RTC/WebSocket 等)进行一层协议抽象,以达到面向协议开发以抹平不同终端差异性的目的。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic7.png\" alt=\"数据序列帧抽象\" />\n <figcaption>图七:数据序列帧抽象</figcaption>\n</figure>\n\n第二层是数据层的抽象。此处的数据并不是面向后端服务的数据层,而是前端 UI 交互的数据层抽象。我们将 UI 交互的状态以全局帧数据的形式抽象出来,当 UI 发生变化则同步至帧数据;当然,如果帧数据被发生改动(修改帧数据对象)则也会驱动 UI 发生相同变化。这个过程通过 JavaSciprt 中 Proxy 拦截数据对象实现的,如图七。换言之,UI 交互能产生新的帧数据,通过帧数据也能还原对应的 UI 状态。至于,为什么要花费大量精力做这个工作后文讲解业务部分时会有详细介绍。\n\n第三层 Web 层有两个方向的核心服务,其中基于 Node.js/Go 实现的 HTTP 服务主要提供 VR 页面的 HTML\"壳子\"和首屏数据,而基于 WebSocket 服务的全双工数据通道则保障了 VR 体验过程与后台服务的实时通讯。WebSocket 长链接技术有传统 HTTP 方式无可比拟的优势(协议私有、实时性高、性能优异等),对我们业务的智能化、性能体验提升等无可替代,下文描述业务探索和性能体验部分大家会有更深切的感知。\n\n贝壳如视用户端的前端设计大致如此,我们大部分核心业务如 VR 语言导览、VR 实时带看和 AR 讲房等都是基于此设计研发的。\n\n### 基于 3D 模型与传统 DOM 开发的差异性对比\n\n作为一名工作频繁接触 3D 相关技术的研发工程师,经常被咨询基于 3D 模型研发与传统 DOM 开发的区别。与传统前端开发差异性是存在的,但是适应如下三点基本就迈入前端 3D 开发的门槛。\n\n#### 三维坐标系 vs DOM 树\n\n前端 DOM 树布局是基于 CSS 盒子模型和 Flex 布局,页面大部分布局都是基于此实现的,此外还有圣杯、双飞翼等经典布局体系。在二维层面依托强大的 CSS,前端布局是随心所欲的。但是放在三维空间,我们大部分时间都在跟坐标系及坐标系间切换打交道。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic8.png\" alt=\"三维建模坐标体系\" />\n <figcaption>图八:三维建模坐标体系</figcaption>\n</figure>\n\n三维研发的首个门槛就是跟各种坐标系打交道,比如三维物体本身的坐标系(一般称呼为本地坐标系),一个三维空间会存在多个三维物体,如何放置这些三维物体则需要一个三维世界坐标系来定位。此外,三维空间的三维物体通常都是静止的,其移动、旋转等操作都是控制相机的移动来实现的(当然,相机也是一种特殊的三维物体),如图八所示。然而,我们终端设备的屏幕是二维的,相机作为一个\"眼睛\"将三维物体投影到二维屏幕上又涉及到平面坐标系、齐次坐标系等等。所以,如何理清这些坐标系的概念和坐标系间的相互转换是 3D 研发的首个门槛,搞清这些在日后的研发中就能做到\"游刃有余\"。\n\n#### 面向异步 Hooks 事件\n\n在处理三维模型行为交互体验时与传统前端还有个很明显的差异就是面临的异步细节要多得多。在 DOM 层面前端开发时,我们接触的异步事件主要集中在点击、触摸、滚动和 Ajax 异步请求等。但是在三维交互中,除此之外我们还频繁接触放大缩小、拖拽位移、模式切换等各类异步行为。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9left.gif\" alt=\"全景走点效果\" /></div>\n <div style=\"flex: 3;padding: 0 0 0 10px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9right.png\" alt=\"涉及异步hooks事件\" /></div>\n </div>\n <figcaption>图九:点位全景切换走点</figcaption>\n</figure>\n\n在如视内部的底层渲染引擎中,我们维护了比较完善的异步 Hooks 事件集来应对各种场景的交互行为。比如,如图九(左)效果是我们常见的 VR 房源点位全景交互走点移动,整个过程触发了九个异步事件回调,如图九(右)所列。这些回调将整个过程的细节全部暴露出来,方便研发人员更精准地把控体验。一般的终端工程师很难体验这种交互层面细维度精准把控的开发体验,初次接触需要适应。\n\n#### 碰撞检测\n\n最后一个比较明显的差异性是三维空间里面的碰撞监测。\n\n<figure>\n <img style=\"width: 50%;min-width: 200px;\" src=\"//solome.js.org/static/gmtc-vr3d/pic10.gif\" alt=\"物体间遮挡与重叠\" />\n <figcaption>图十:物体间遮挡与重叠</figcaption>\n</figure>\n\n如图十所示,在三维空间中摆置新物体难免会涉及遮盖、重叠的情况。在实际开发中,我们尽量规避这种现象的发生。碰撞监测常规的做法是针对物体创建一个规则的立体几何外形将其包围然后分析是否有重叠的部分;还有种思路是建立一条射线,获取此射线与两个物体间的焦点然后分析是否重合。\n碰撞监测在不同的场景一般会采用合适的方式,对于移动的物体,有时候我们还需要在建模体系中添加物理引擎的支持。碰撞检测在不同的业务场景下,检测的策略是不同的,这个比较考验研发对整个三维空间的理解能力,本文就不展开更细节的内容了。\n\n## 新型业务场景探索与实践\n\n前文涉及的都是偏技术领域的,下面向大家分享下在已有的技术储备下,如视是如何在业务上做的一些探索与实践的。\n\n### 三维空间分析计算与二次加工\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic11.gif\" alt=\"物体(家具)识别\" />\n <figcaption>图十一:物体(家具)识别</figcaption>\n</figure>\n\n三维模型是来源于现实真实的房源(通过专业设备拍摄及算法分析获取),我们可以对三维模型进行分析并将里面的家具物体识别出来(如图十一所示)。识别出这些物体后我们就能做些有趣的事情了,比如识别出显示器或电视,可以在此处添加一个视频播放广告或节目来营造更加真实的 3D 场景,效果如图十二(左)。识别平滑地面,我们可以放置一个扫地机器人或 3D 宝箱来做些营销活动等等,效果如图十二(中)、(右)。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12left.gif\" alt=\"电视视频\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12center.gif\" alt=\"扫地机器人\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;transform: scale(0.9153225806451613);transform-origin: left;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12right.gif\" alt=\"宝箱营销\" /></div>\n </div>\n <figcaption>图十二:根据物体识别添加动态内容</figcaption>\n</figure>\n\n除了空间内的物体识别之外,户型图也是我们二次加工的重点方向。比如,我们将二手房源里面家具及装修物体全部清理掉,然后就得到一个及其\"纯净\"的白模模型;在基于原有的户型结构重新规划将一个两室一厅的房源改造成一个三室一厅的房源,然后再重新加工装修风格和摆置家居物体等。\n\n整个过程,如图十三(左)所示,经历了从真实复杂的普通房源到简洁的白模再到复杂的新装修家居风格过程,给潜在的购房用户提前示例这套房源的改造空间。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 7;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13left.png\" alt=\"加工过程\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13right.gif\" alt=\"一键切换\" /></div>\n </div>\n <figcaption>图十三:真实房源的二次加工</figcaption>\n</figure>\n\n此外,我们在技术体验上也做了些突破,在终端层面实现真实房源与设计房源一键切换和同屏对比的交互体验,最终效果如图十三(右)所示。\n\n### VR 实时带看:同屏连线,高效看房\n\n另外一个业务场景探索则是线上 VR 实时带看能力的落地。首先,解释下为什么要往这个方向探索?大家有过买房或租房体验的都知道,大部分场景都是经纪人开车载着你去实地看房,一天下来也就看几套房源可能还要爬楼梯、等红绿灯或被太阳曝晒等意外情况。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 4;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14left.png\" alt=\"3D 交互与二维交互对比\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right1.gif\" alt=\"VR 同屏1\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right2.gif\" alt=\"VR 同屏2\" /></div>\n </div>\n <figcaption>图十四:3D 交互与二维交互对比及 VR 同屏</figcaption>\n</figure>\n\n尽管 VR 房源虽然还原了房源的真实场景,但是三维空间交互还是比较复杂的,需要用户去探索细节。如图十四(左)是经典的信息流布局:搜索 ➙ 导航 ➙ 推荐 ➙ 筛选 ➙ 列表,这是二维最高效的信息展示布局,国内绝大部分提供数据服务的 App(电商京东、餐饮美团、房产贝壳等)均是这类布局。\n\n但是三维空间交互就没有这么明确了,全景只能查看当前点位且全景游走大部分用户并不知晓。此外,诸如房源的小区信息和附近学校、医院等信息也无法在 VR 3D 模型中明确体现。因此,我们实现了由用户无目的的在 VR 3D 模型中漫游、探索信息转向专业由经纪人带领画面同步、实时语言讲解。\n\n前文提到我们将前端所有的交互以序列帧数据的形式进行了抽象,用户交互会产生帧数据然后通过 WebSocket 将生成的帧数据同步给另外一个用户来驱动另外一个用户画面的更新。语音的话目前 RTC 技术比较成熟,我们落地即可,效果如图十四(右)所示。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic15.png\" alt=\"终端App与微信小程序VR 实时带看通道链路\" />\n <figcaption>图十五:终端App与微信小程序VR 实时带看通道链路</figcaption>\n</figure>\n\n除了端与端 VR 带看之外,我们还实现终端 App(iOS/Android)与微信小程序的 VR 实时语音带看的业务能力,整个链路通道如图十五所示。\n\n线上 VR 实时带看能力在 2018 年底我们就已经初步实现落地,由于 2020 年新冠疫情影响造成大批潜在购房用户和经纪人居家隔离,线上 VR 实时带看目前已经成为了看房业务的核心场景。\n\n### VR 智能讲房:智能解说,身临其境\n\n前面提到 VR 带看是通过专业的经纪人陪同去了解房源解决 VR 3D 看房获取信息的方式不高效问题。但这个业务场景也存在些许缺陷:\n\n- 人力成本:经纪人不一定能及时响应,比如深夜休息时段。\n- 专业水平:不能保障经纪人对所有的房源都了解,又诸如方言等沟通效率。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic16.gif\" alt=\"社交恐惧症\" />\n <figcaption>图十六:“社交恐惧症”:客户不愿跟陌生人沟通</figcaption>\n</figure>\n\n- 顾客“社交恐惧症”:不是人人都愿意跟陌生人沟通等。\n\n鉴于此,我们尝试把 VR 3D 交互做得更智能些。怎么做才更智能呢?首先,我们得不完全依赖真实的经纪人。我们将真实的经纪人形象和音色采集出来然后通过视频拼接和语言 TTS 服务来抽象出一个虚拟经纪人,并将此虚拟经纪人形象搬到用户的终端屏幕上,如图十七所示。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic17.gif\" alt=\"虚拟数字经纪人\" />\n <figcaption>图十七:虚拟数字经纪人</figcaption>\n</figure>\n\n有了虚拟的经纪人,那么该讲解什么样的内容呢?VR 带看语音来自于经纪人,画面行为帧数据也来源于经纪人行为。此时,就需要通过算法层面去合成讲稿并生产对应的音频和序列帧数据。整体的架构如图十八所示,前端所需要支持的就是定义画面行为的序列帧数据格式规范,由 AI 团队的剧本服务和 NLG 服务去计算 LRC 文本讲稿和行为序列。然后,通过主控服务生成带讲稿音频虚拟经纪人视频并附带行为序列帧数据给前端\"翻译\"。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic18.png\" alt=\"AR 讲房架构\" />\n <figcaption>图十八:AR 讲房架构</figcaption>\n</figure>\n\n因为涉及的点过多,更多的细节本文就不再详细讲解了。大家可以扫描图十九的二维码或访问 [珠江罗马嘉园东区 2 室 1 厅](//open.realsee.com/ke/15XKMYpVwOw3R7j8/BoZqQK8KmaAtncxhvTYre9ztvW9D50zg/?v3=1) 这套房源进行体验。总之,由于 WebSocket 双工实时性和前端序列帧数据抽象,VR 的整体体验变得更加智能化。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19left.png\" alt=\"体验二维码\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19right.png\" alt=\"入口位置\" /></div>\n </div>\n <figcaption>图十九:AR 讲房体验二维码</figcaption>\n</figure>\n\n## 面临的性能挑战及应对方案\n\n在过去三年的 VR 看房及衍生业务研发中我们主要面临的性能瓶颈有两个:加载耗时和内存溢出。\n\n### 加载耗时\n\n在 2019 年 8 月份前,贝壳如视 VR 首屏加载平均耗时 7.6s,截至 2021 年 7 月份已经降至 1.92s,正常网络情况下用户基本无需等待过多时间去体验 VR 房源。如此巨大的提升我们究竟做了些什么呢?首先我们先分析之前慢的原因,然后\"对症下药\"。而且首屏的性能提升也不是一蹴而就的事情,我们内部成立了个性能体验专项虚拟团队持续了近一年才达到最终 1.92s 的效果。\n\n问题出在哪儿呢?主要在三个方面:\n\n#### 密集的 HTTP 请求\n\n前文提到 VR 3D 模型依赖大量的模型 UV 贴图和全景图片;除此之外,还有大量的地图、讲房音视频等资源。在浏览器的限制下同个域下的 CDN 请求限制在 3~6 个(不同浏览器会有差异)。大量的网络请求只能排队等待。\n\n#### 实时计算\n\n前端存在大量的实时计算,比如 3D 模型文件的解压缩、户型图数据解析、三维空间分析及碰撞监测等。由于 JavaScript 的单进程,这些计算依赖也阻塞一些核心逻辑。\n\n#### 模块渲染加载策略不合理\n\n由于 VR 开发初期考虑不周全,我们的异步渲染加载策略设计并不合理,优先级策略划分错乱。\n\n分析原因后,优化策略就很明确了。针对密集的 HTTP 请求我们先添加更多 CDN 域名支持,保障同时刻的请求限制在五个以内并增加 HTTP2 协议支持。实时计算带来的耗时采取的策略是充分利用缓存(离线计算缓存、浏览器缓存以及服务端计算缓存等);同时,我们对模块渲染加载策略进行了重新设计,每个模块都规划好权重,按照权重来加载。此外,部分非核心交互则由用户触发后再加载渲染。由于历史包袱过重,真个过程持续了近一年,最终有了 7.6s 到 2.55s 的首屏加载的性能提升,过程如图二十(左)所示。\n\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 3;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20left.png\" alt=\"耗时变化\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20right.gif\" alt=\"加载效果\" /></div>\n </div>\n <figcaption>图二十:VR 首屏性能提升过程</figcaption>\n</figure>\n\n除上文提到的优化之外,我们还充分挖掘了部分客户端的能力。第一个能力是**客户端 HTTP 请求拦截代理和缓存**,通常情况下 WebView 缓存池\"阈值\"很低,而客户端缓存池则大得多;此外,分析对比来看客户端的 HTTP 请求效率要比 WebView 的 HTTP 请求高很多。支持 HTTP 请求代理和缓存之后,整个加载耗时降低了近 500ms。\n\n另外一个核心能力则是增加了**客户端首屏渲染**:即进入 VR 页面前客户端提前预载好首屏内容,在加载阶段展示客户端内容,等前端完成首屏渲染之后再换成前端的渲染效果。整个过程是无缝的,用户甚至感知不到加载过程,最终的效果如图二十(右)所示。\n\n### 内存溢出\n\n加载耗时现阶段已经取得比较好的效果,我们目前遭遇的最大的瓶颈是内存溢出。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic21.png\" alt=\"VR 内存占用\" />\n <figcaption>图二十一:VR 内存占用</figcaption>\n</figure>\n\n在前文首屏优化中提到我们耗费大量的时间完善了模块加载渲染策略,因此在 VR 交互过程中,随着各个模块不断完成渲染,内存占用是逐步递增的,如图二十一(左)所示。在图二十一(右)扇形图中也列举了不同模块的内存占用情况。目前,iOS 设备的 WebView 内存崩溃的阈值大约在 1.5G 左右,Android 设备则不同机型阈值不完全一致,高端 Android 设备普遍比 iOS 设备高很多,但低端机阈值远低于 1.5G 内存。\n\n规避内存溢出问题我们从两个方向入手:\n\n#### 增加内存池\n\n目前我们测试过 iOS/Android 设备各类 WebView 控件,除了实现 WebView 独立进程之外并没有找到突破 WebView 内存限制的方式。这个属于 WebView 容器瓶颈。\n\n#### 降低内存占用\n\n我们做了些突破,比如按需渲染,非可视区域销毁模块等等,但仅仅降低了崩溃率,成效并不明显。\n\n而且,随着业务的不断迭代,VR 能力愈来愈丰富,内存占用还在不断提升。依赖 WebView+WebGL+jsBridge 技术栈落地的 VR 体验现阶段有很明显的局限性,虽然纯原生技术栈已经提上日程但短期来看还是很难落地的。为了弱化内存溢出带来的影响,我们目前采取的策略是根据用户的使用场景以动态降级的方式给予用户最合适的交互体验。\n\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic22.png\" alt=\"VR 性能瓶颈影响因素鱼骨图\" />\n <figcaption>图二十二:VR 性能瓶颈影响因素鱼骨图</figcaption>\n</figure>\n\n性能优化的本质是渐进增强和优雅降级,把握每个细节把自己该做的部分做好一般都会有比较好的性能表现。我们系统分析了造成性能瓶颈各个因素,如图二十二所示。事实上,我们很难做些突破然后彻底解决内存问题,只能降级保障体验。\n\n如何做到更\"智能\"地渐进增强和优雅降级?首先需要的是前端支持模块的\"热插拔\"能力,即能动态的销毁某个模块以将内存空间给其他模块使用。此外,我们维护一个关于内存瓶颈的数据仓库,依托 WebSocket 的双工能力,VR 交互时会收集用户的终端设备信息及部分 VR 用户行为,并在实时分析该用户的终端的最大承受能力,推送给前端再动态地加载或卸载前端模块,从而达到加强体验或降级的效果。\n\n## 总结\n\n前面给大家讲述了贝壳如视前端团队如何基于 VR 及 3D 技术在 Web 领域架构设计,并分享了在这个领域上的一些业务探索、实践及应对性能瓶颈的具体措施。本次的演讲的专题是\"移动技术新趋势\",最后站在技术的角度上做如下四个方面的经验(或趋势)总结来结束本次的演讲内容吧。\n\n### 可玩性\n\n三维领域研发比传统基于 DOM 前端研发有趣得多,比如团队就有产品说过三维空间二次加工装修设计是更高阶的\"乐高\"式游戏,欢迎大家加入这个领域。\n\n### 序列帧抽象及数据驱动\n\n过往的前端交互都是用户主动触发的,但是在 3D 方向的交互模型更需要自动播放,提高信息获取的方式。前端数据层序列帧抽象,支持数据驱动、序列化和反序列化将是不可或缺的一环。\n\n### \"热插拔\"\n\n3D 领域开发内存占用是远大于传统前端页面的,尤其在终端设备 WebView 容器下内存限制更明显。模块、组件及插件等封装都需要支持\"热插拔\",从而做到动态加强体验或降级的效果。\n\n### WebSocket\n\n我们已经逐步在抛弃主动式 Ajax,数据的实时性和智能化都依赖 WebSocket 的双工能力。目前,WebSocket 服务已经是核心基础建设。\n","slug":"gmtc-vr3d","published":1,"updated":"2023-11-07T15:48:15.533Z","photos":[],"link":"","_id":"clooia2lv0001ln3ycg4b3ff3","content":"<blockquote>\n<p>本文基于 2021 年 GMTC 全球大前端技术大会"移动技术新趋势"专题下主题分享<a href=\"//gmtc.infoq.cn/2021/beijing/presentation/3531\">《VR 及 3D 技术在 Web 端架构设计与实践》</a>整理而来。内容与当日分享基本无异,仅以文字的形式重新整理一遍。</p>\n</blockquote>\n<style type=\"text/css\">\narticle .ref {display: flex;line-height: 1;border: none;border-radius: 4px;padding: 12px;margin: 12px 0 0;background-color: rgb(239 236 236);border-bottom:none;font-family: Roboto, -apple-system, BlinkMacSystemFont, sans-serif;}article .ref:hover {background-color: rgb(228 223 223);}.ref-img {width: 56px;height: 56px;}.ref-content {padding: 0 0 0 12px;flex: 1;flex-direction: column;display: flex;min-width: 10px;}.ref-title, .ref-subTitle, .ref-link {overflow: hidden;white-space: nowrap;text-overflow: ellipsis;}.ref-title {font-size: 16px;font-weight: 500;line-height: 20px;color: rgb(31, 34, 37);}.ref-subTitle {line-height: 19px;}.ref-link {line-height: 17px;color: rgb(161, 162, 163);}\n.fancyboxflex .fancybox {flex: 1;}\n</style>\n<a class=\"ref\" href=\"//gmtc.infoq.cn/2021/beijing/presentation/3531\" target=\"_blank\">\n <img class=\"ref-img\" src=\"//solome.js.org/static/gmtc-vr3d/5c7caa3b1c540.jpeg\">\n <div class=\"ref-content\">\n <div class=\"ref-title\">GMTC_全球大前端技术大会-InfoQ</div>\n <div class=\"ref-subTitle\">\"GMTC是由极客邦科技和InfoQ中国主办的顶级技术盛会,关注移动、前端、AI应用等多个技术领域,促进全球技术交流,推动国内技术升级。GMTC为期4天,包括两天的会议和两天的培训课,主要面向各行业对移动开发、前端、AI技术感兴趣的中高端技术人员,大会聚焦前沿技术及实践经验,旨在帮助参会者了解移动开发&前端领域最新的技术趋势与最佳实践。\"</div>\n <div class=\"ref-link\">https://gmtc.infoq.cn/2021/beijing/presentation/3531</div>\n </div>\n</a>\n\n<p>VR 看房是 VR 及 3D 技术落地的场景之一,其特点是通过手机终端就能让人真正的置身其中,用自己直觉的空间感去感受整个房屋特征。本次分享将介绍贝壳如视前端团队是如何基于 VR 3D 模型进行前端架构设计的。除此之外,还将分享我们团队是如何基于 VR 看房能力探索新的业务形式以及面临的技术挑战。</p>\n<h2 id=\"基于-VR-3D-模型前端架构设计\"><a href=\"#基于-VR-3D-模型前端架构设计\" class=\"headerlink\" title=\"基于 VR 3D 模型前端架构设计\"></a>基于 VR 3D 模型前端架构设计</h2><p>在讲前端架构设计之前,先详细介绍下看房场景下的 VR 3D 模型的组成及形态。</p>\n<h3 id=\"看房-VR-3D-模型的组成及形态\"><a href=\"#看房-VR-3D-模型的组成及形态\" class=\"headerlink\" title=\"看房 VR 3D 模型的组成及形态\"></a>看房 VR 3D 模型的组成及形态</h3><p>房源的 VR 3D 模型的形态有多种,但在用户层面直观感受到的主要有三个形态:3D 模型形态、点位全景形态及 VR 眼镜视角形态。下面对这三个形态做详细介绍。</p>\n<h4 id=\"3D-模型\"><a href=\"#3D-模型\" class=\"headerlink\" title=\"3D 模型\"></a>3D 模型</h4><p>首先,我们简单思考一下三维模型是如何在二维平面抽象建模的?目前主流的三维模型抽象建模是基于多边形网格(Polygon Mesh),如图一所示。整体感知就是多边形面片愈多(面片密度)还原的三维立体效果愈真实。最精简的多边形自然是三角形(大部分场景下说的面片即三角面片),三维物体的每个细节可以通过三角面片的顶点、边及面等几何数学概念来描述。微观上来看,基于面片建模的三维模型本质上都是密度及其复杂的几何体。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic1.png\" alt=\"多边形网格模拟立体效果\" />\n <figcaption>图一:多边形网格模拟立体效果</figcaption>\n</figure>\n\n<p>因此,依赖一些专业 3D 扫描仪(比如如视自研的黎曼、伽罗华等扫描仪)或全景相机等设备采集数据后,再通过算法加工可以获取这些描述三维立体结构的三角面片数据。前端再利用 WebGL/Three.js 等技术将其渲染至浏览器上,此时我们能得到房源的三维立体轮廓,效果如图二(左)所示的网格模型。当然,图二(右)才是我们期望的效果,仅仅有三维"骨架"轮廓是不够的,我们需要在此基础上贴一层"皮肤",而这层"皮肤"则是通过 UV 纹理贴图添加上的。</p>\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2left.gif\" /></div>\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2right.gif\" /></div>\n </div>\n <figcaption>图二:三角面片描述的三维效果</figcaption>\n</figure>\n\n<p>对于三维模型有两个比较重要的坐标系统,一个是顶点的位置<code>(x,y,z)</code>坐标,另一个则是 UV 坐标。什么是 UV 呢?简言之,就是二维平面贴图映射到三维模型表面的依据。比如典型的 UV 贴图效果如图三所示,刚刚前文提到三维结构是通过顶点、边及面组成的三角面片组成的,这个三角面是二维的,通过一些数据依赖映射关系从 UV 贴图中抠出一个相同边、面的三角形贴到三角面片上。所以,此处的 UV 即指定义了二维平面图片每个点的位置与三维结构三角面片位置的映射关系信息。作为前端工程师,这个跟前端雪碧图(Sprite)概念将多个图标合并成一张图的原理是一致的。</p>\n<figure>\n <img style=\"width: 35%\" src=\"//solome.js.org/static/gmtc-vr3d/pic3.png\" alt=\"房源UV贴图\" />\n <figcaption>图三:房源UV贴图</figcaption>\n</figure>\n\n<p>至此,基于三角面片和 UV 贴图数据我们成功渲染出了房源的 3D 模型。当然,出于性能考虑我们的三角面片密度不是特别高的,纯粹依靠 3D 模型在终端设备(iOS\\Android 等)还原房源的真实细节现阶段并不现实。三角面片少,数据量低,内存占用低,我们可以通过 3D 模型还原房源的整体结构。至于细节,则通过点位立方体全景的方式去实现。</p>\n<h4 id=\"点位全景\"><a href=\"#点位全景\" class=\"headerlink\" title=\"点位全景\"></a>点位全景</h4><p>前文提到房源的整体结构通过 3D 模型体现,至于细节则通过全景的形式来表现。我们会在房源选择多个合适的点位拍摄全景图片,然后以立方体全景的方式渲染以实现 720 º 环顾的效果,如图四(左)所示。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4left.gif\" alt=\"全景效果\" /></div>\n <div style=\"flex: 14;padding: 0 0 0 40px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4right.png\" alt=\"全景贴图展开\" /></div>\n </div>\n <figcaption>图四:立方体全景效果及其展开</figcaption>\n</figure>\n\n<p>全景的实现是比较成熟的技术,主流的实现方式有立方体全景和球型全景。两种方式各有优缺点,由于立方体全景二次加工成本低如视目前以立方体全景技术实现为主。立方体全景的原理是渲染一个立方体盒子,给其上、下、前、后、左和右六个面各贴上一张图。需要注意的是,这六张图从中选择连续的四张图拼接在一起是一张连贯的全景图,如图四(右)所示 T 字形立方体贴图展开。此时,当人眼放置在立方体中心点观望四周是连贯的全景效果。</p>\n<p>全景的效果完全依赖贴图的清晰度,所以我们可以拍摄高清 2048 分辨率的全景图片去体现房源某个位置的细节信息。这也是看房 VR 3D 模型的第二个核心形态点位全景形态。</p>\n<h4 id=\"VR-眼镜全景\"><a href=\"#VR-眼镜全景\" class=\"headerlink\" title=\"VR 眼镜全景\"></a>VR 眼镜全景</h4><p>前文提到的 3D 模型和点位全景形态都是基于二维显示屏展现的(裸眼体验),如果想让用户具备身临其境的感觉往往需要依赖 VR 眼镜设备。针对这类设备我们需要适配<a href=\"//www.w3.org/TR/webxr/\">WebXR Device API</a>,我们现阶段的适配策略是渲染两个相同的点位立方体全景,分别供左右眼感知。最终适配的效果如图五所示。</p>\n<figure>\n <div style=\"flex: 1;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic5left.gif\" alt=\"眼镜全景\" /></div>\n <figcaption>图五:VR 眼镜全景</figcaption>\n</figure>\n\n<p>限于大部分用户的设备还是 iOS\\Android,目前的裸眼 VR 3D 体验是主流。随着硬件设备的推广,等到 VR 眼镜走向普通用户时,这种更具身临其境的体验会慢慢更多用户接触到。</p>\n<p>当然,除了本文提到 3D 模型形态、点位全景和 VR 眼镜全景三种形态之外,我们内部还有多种其他形态,如模型垂直视角、深度图渲染的全景视角等形态,但是偏技术领域且与普通用户感知不深,此处不详细介绍了。</p>\n<p>最后,基于这三种形态外加一个房源的二维户型图就组成了我们看房 VR 3D 模型的核心结构,在此基础不断完善各种交互(比如形态间切换补间 Tween 动画)、产品功能逐步演变成大家所熟悉的贝壳如视 VR 看房。</p>\n<blockquote>\n<p>演讲问答环节及后续的反馈情况来看,大家对分享提到的形态间切换的 Tween 动画实现比较感兴趣,且部分同行表示自己实现的效果达不到如视的移动真实感。此处细节较多,准备后续单独出文章分享,本文暂不花费篇幅详细介绍。</p>\n</blockquote>\n<h3 id=\"前端架构分层设计\"><a href=\"#前端架构分层设计\" class=\"headerlink\" title=\"前端架构分层设计\"></a>前端架构分层设计</h3><figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic6.png\" alt=\"前端架构分层设计\" />\n <figcaption>图六:前端架构分层设计</figcaption>\n</figure>\n\n<p>前文提到房源的 VR 3D 模型的组成及三个核心形态,我们实现了通过 3D 技术真实还原房源信息。经过多轮的产品需求迭代,我们在 VR 3D 模型的基础上不断地完善整个前端的架构分层设计。现阶段,整个 VR 用户端前端设计中我们抽象了三层:Web 服务层、前端数据层和 View 层。</p>\n<p>我们将 View 层划分成四个方向进行抽象,第一个方向是纯 DOM 层的,比如首屏内容、控制面板、信息面板等,这层我们通常以 React/Vue 组件进行抽象服用。第二个方向是基于 Canvas/WebGL 渲染的三维视图,其功能即前文提到的房源 VR 3D 模型交互。第三个方向是我们维护的 3D 插件生态,以 VR 3D 模型为基础且以插件的形式派生出新的交互、能力(比如,模型中的指南针、电视视频等均以插件的形式集成)。最后一个方向是协议层抽象,我们 VR 是通过 Web 前端技术渲染实现的,以 WebView 作为容器集成在终端 App 里面,通过 jsBridge 的方式实现双向通信。为了保障业务代码的统一性,我们将第三方依赖(jsBridge/RTC/WebSocket 等)进行一层协议抽象,以达到面向协议开发以抹平不同终端差异性的目的。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic7.png\" alt=\"数据序列帧抽象\" />\n <figcaption>图七:数据序列帧抽象</figcaption>\n</figure>\n\n<p>第二层是数据层的抽象。此处的数据并不是面向后端服务的数据层,而是前端 UI 交互的数据层抽象。我们将 UI 交互的状态以全局帧数据的形式抽象出来,当 UI 发生变化则同步至帧数据;当然,如果帧数据被发生改动(修改帧数据对象)则也会驱动 UI 发生相同变化。这个过程通过 JavaSciprt 中 Proxy 拦截数据对象实现的,如图七。换言之,UI 交互能产生新的帧数据,通过帧数据也能还原对应的 UI 状态。至于,为什么要花费大量精力做这个工作后文讲解业务部分时会有详细介绍。</p>\n<p>第三层 Web 层有两个方向的核心服务,其中基于 Node.js/Go 实现的 HTTP 服务主要提供 VR 页面的 HTML"壳子"和首屏数据,而基于 WebSocket 服务的全双工数据通道则保障了 VR 体验过程与后台服务的实时通讯。WebSocket 长链接技术有传统 HTTP 方式无可比拟的优势(协议私有、实时性高、性能优异等),对我们业务的智能化、性能体验提升等无可替代,下文描述业务探索和性能体验部分大家会有更深切的感知。</p>\n<p>贝壳如视用户端的前端设计大致如此,我们大部分核心业务如 VR 语言导览、VR 实时带看和 AR 讲房等都是基于此设计研发的。</p>\n<h3 id=\"基于-3D-模型与传统-DOM-开发的差异性对比\"><a href=\"#基于-3D-模型与传统-DOM-开发的差异性对比\" class=\"headerlink\" title=\"基于 3D 模型与传统 DOM 开发的差异性对比\"></a>基于 3D 模型与传统 DOM 开发的差异性对比</h3><p>作为一名工作频繁接触 3D 相关技术的研发工程师,经常被咨询基于 3D 模型研发与传统 DOM 开发的区别。与传统前端开发差异性是存在的,但是适应如下三点基本就迈入前端 3D 开发的门槛。</p>\n<h4 id=\"三维坐标系-vs-DOM-树\"><a href=\"#三维坐标系-vs-DOM-树\" class=\"headerlink\" title=\"三维坐标系 vs DOM 树\"></a>三维坐标系 vs DOM 树</h4><p>前端 DOM 树布局是基于 CSS 盒子模型和 Flex 布局,页面大部分布局都是基于此实现的,此外还有圣杯、双飞翼等经典布局体系。在二维层面依托强大的 CSS,前端布局是随心所欲的。但是放在三维空间,我们大部分时间都在跟坐标系及坐标系间切换打交道。</p>\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic8.png\" alt=\"三维建模坐标体系\" />\n <figcaption>图八:三维建模坐标体系</figcaption>\n</figure>\n\n<p>三维研发的首个门槛就是跟各种坐标系打交道,比如三维物体本身的坐标系(一般称呼为本地坐标系),一个三维空间会存在多个三维物体,如何放置这些三维物体则需要一个三维世界坐标系来定位。此外,三维空间的三维物体通常都是静止的,其移动、旋转等操作都是控制相机的移动来实现的(当然,相机也是一种特殊的三维物体),如图八所示。然而,我们终端设备的屏幕是二维的,相机作为一个"眼睛"将三维物体投影到二维屏幕上又涉及到平面坐标系、齐次坐标系等等。所以,如何理清这些坐标系的概念和坐标系间的相互转换是 3D 研发的首个门槛,搞清这些在日后的研发中就能做到"游刃有余"。</p>\n<h4 id=\"面向异步-Hooks-事件\"><a href=\"#面向异步-Hooks-事件\" class=\"headerlink\" title=\"面向异步 Hooks 事件\"></a>面向异步 Hooks 事件</h4><p>在处理三维模型行为交互体验时与传统前端还有个很明显的差异就是面临的异步细节要多得多。在 DOM 层面前端开发时,我们接触的异步事件主要集中在点击、触摸、滚动和 Ajax 异步请求等。但是在三维交互中,除此之外我们还频繁接触放大缩小、拖拽位移、模式切换等各类异步行为。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9left.gif\" alt=\"全景走点效果\" /></div>\n <div style=\"flex: 3;padding: 0 0 0 10px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9right.png\" alt=\"涉及异步hooks事件\" /></div>\n </div>\n <figcaption>图九:点位全景切换走点</figcaption>\n</figure>\n\n<p>在如视内部的底层渲染引擎中,我们维护了比较完善的异步 Hooks 事件集来应对各种场景的交互行为。比如,如图九(左)效果是我们常见的 VR 房源点位全景交互走点移动,整个过程触发了九个异步事件回调,如图九(右)所列。这些回调将整个过程的细节全部暴露出来,方便研发人员更精准地把控体验。一般的终端工程师很难体验这种交互层面细维度精准把控的开发体验,初次接触需要适应。</p>\n<h4 id=\"碰撞检测\"><a href=\"#碰撞检测\" class=\"headerlink\" title=\"碰撞检测\"></a>碰撞检测</h4><p>最后一个比较明显的差异性是三维空间里面的碰撞监测。</p>\n<figure>\n <img style=\"width: 50%;min-width: 200px;\" src=\"//solome.js.org/static/gmtc-vr3d/pic10.gif\" alt=\"物体间遮挡与重叠\" />\n <figcaption>图十:物体间遮挡与重叠</figcaption>\n</figure>\n\n<p>如图十所示,在三维空间中摆置新物体难免会涉及遮盖、重叠的情况。在实际开发中,我们尽量规避这种现象的发生。碰撞监测常规的做法是针对物体创建一个规则的立体几何外形将其包围然后分析是否有重叠的部分;还有种思路是建立一条射线,获取此射线与两个物体间的焦点然后分析是否重合。<br>碰撞监测在不同的场景一般会采用合适的方式,对于移动的物体,有时候我们还需要在建模体系中添加物理引擎的支持。碰撞检测在不同的业务场景下,检测的策略是不同的,这个比较考验研发对整个三维空间的理解能力,本文就不展开更细节的内容了。</p>\n<h2 id=\"新型业务场景探索与实践\"><a href=\"#新型业务场景探索与实践\" class=\"headerlink\" title=\"新型业务场景探索与实践\"></a>新型业务场景探索与实践</h2><p>前文涉及的都是偏技术领域的,下面向大家分享下在已有的技术储备下,如视是如何在业务上做的一些探索与实践的。</p>\n<h3 id=\"三维空间分析计算与二次加工\"><a href=\"#三维空间分析计算与二次加工\" class=\"headerlink\" title=\"三维空间分析计算与二次加工\"></a>三维空间分析计算与二次加工</h3><figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic11.gif\" alt=\"物体(家具)识别\" />\n <figcaption>图十一:物体(家具)识别</figcaption>\n</figure>\n\n<p>三维模型是来源于现实真实的房源(通过专业设备拍摄及算法分析获取),我们可以对三维模型进行分析并将里面的家具物体识别出来(如图十一所示)。识别出这些物体后我们就能做些有趣的事情了,比如识别出显示器或电视,可以在此处添加一个视频播放广告或节目来营造更加真实的 3D 场景,效果如图十二(左)。识别平滑地面,我们可以放置一个扫地机器人或 3D 宝箱来做些营销活动等等,效果如图十二(中)、(右)。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12left.gif\" alt=\"电视视频\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12center.gif\" alt=\"扫地机器人\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;transform: scale(0.9153225806451613);transform-origin: left;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12right.gif\" alt=\"宝箱营销\" /></div>\n </div>\n <figcaption>图十二:根据物体识别添加动态内容</figcaption>\n</figure>\n\n<p>除了空间内的物体识别之外,户型图也是我们二次加工的重点方向。比如,我们将二手房源里面家具及装修物体全部清理掉,然后就得到一个及其"纯净"的白模模型;在基于原有的户型结构重新规划将一个两室一厅的房源改造成一个三室一厅的房源,然后再重新加工装修风格和摆置家居物体等。</p>\n<p>整个过程,如图十三(左)所示,经历了从真实复杂的普通房源到简洁的白模再到复杂的新装修家居风格过程,给潜在的购房用户提前示例这套房源的改造空间。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 7;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13left.png\" alt=\"加工过程\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13right.gif\" alt=\"一键切换\" /></div>\n </div>\n <figcaption>图十三:真实房源的二次加工</figcaption>\n</figure>\n\n<p>此外,我们在技术体验上也做了些突破,在终端层面实现真实房源与设计房源一键切换和同屏对比的交互体验,最终效果如图十三(右)所示。</p>\n<h3 id=\"VR-实时带看:同屏连线,高效看房\"><a href=\"#VR-实时带看:同屏连线,高效看房\" class=\"headerlink\" title=\"VR 实时带看:同屏连线,高效看房\"></a>VR 实时带看:同屏连线,高效看房</h3><p>另外一个业务场景探索则是线上 VR 实时带看能力的落地。首先,解释下为什么要往这个方向探索?大家有过买房或租房体验的都知道,大部分场景都是经纪人开车载着你去实地看房,一天下来也就看几套房源可能还要爬楼梯、等红绿灯或被太阳曝晒等意外情况。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 4;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14left.png\" alt=\"3D 交互与二维交互对比\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right1.gif\" alt=\"VR 同屏1\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right2.gif\" alt=\"VR 同屏2\" /></div>\n </div>\n <figcaption>图十四:3D 交互与二维交互对比及 VR 同屏</figcaption>\n</figure>\n\n<p>尽管 VR 房源虽然还原了房源的真实场景,但是三维空间交互还是比较复杂的,需要用户去探索细节。如图十四(左)是经典的信息流布局:搜索 ➙ 导航 ➙ 推荐 ➙ 筛选 ➙ 列表,这是二维最高效的信息展示布局,国内绝大部分提供数据服务的 App(电商京东、餐饮美团、房产贝壳等)均是这类布局。</p>\n<p>但是三维空间交互就没有这么明确了,全景只能查看当前点位且全景游走大部分用户并不知晓。此外,诸如房源的小区信息和附近学校、医院等信息也无法在 VR 3D 模型中明确体现。因此,我们实现了由用户无目的的在 VR 3D 模型中漫游、探索信息转向专业由经纪人带领画面同步、实时语言讲解。</p>\n<p>前文提到我们将前端所有的交互以序列帧数据的形式进行了抽象,用户交互会产生帧数据然后通过 WebSocket 将生成的帧数据同步给另外一个用户来驱动另外一个用户画面的更新。语音的话目前 RTC 技术比较成熟,我们落地即可,效果如图十四(右)所示。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic15.png\" alt=\"终端App与微信小程序VR 实时带看通道链路\" />\n <figcaption>图十五:终端App与微信小程序VR 实时带看通道链路</figcaption>\n</figure>\n\n<p>除了端与端 VR 带看之外,我们还实现终端 App(iOS/Android)与微信小程序的 VR 实时语音带看的业务能力,整个链路通道如图十五所示。</p>\n<p>线上 VR 实时带看能力在 2018 年底我们就已经初步实现落地,由于 2020 年新冠疫情影响造成大批潜在购房用户和经纪人居家隔离,线上 VR 实时带看目前已经成为了看房业务的核心场景。</p>\n<h3 id=\"VR-智能讲房:智能解说,身临其境\"><a href=\"#VR-智能讲房:智能解说,身临其境\" class=\"headerlink\" title=\"VR 智能讲房:智能解说,身临其境\"></a>VR 智能讲房:智能解说,身临其境</h3><p>前面提到 VR 带看是通过专业的经纪人陪同去了解房源解决 VR 3D 看房获取信息的方式不高效问题。但这个业务场景也存在些许缺陷:</p>\n<ul>\n<li>人力成本:经纪人不一定能及时响应,比如深夜休息时段。</li>\n<li>专业水平:不能保障经纪人对所有的房源都了解,又诸如方言等沟通效率。</li>\n</ul>\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic16.gif\" alt=\"社交恐惧症\" />\n <figcaption>图十六:“社交恐惧症”:客户不愿跟陌生人沟通</figcaption>\n</figure>\n\n<ul>\n<li>顾客“社交恐惧症”:不是人人都愿意跟陌生人沟通等。</li>\n</ul>\n<p>鉴于此,我们尝试把 VR 3D 交互做得更智能些。怎么做才更智能呢?首先,我们得不完全依赖真实的经纪人。我们将真实的经纪人形象和音色采集出来然后通过视频拼接和语言 TTS 服务来抽象出一个虚拟经纪人,并将此虚拟经纪人形象搬到用户的终端屏幕上,如图十七所示。</p>\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic17.gif\" alt=\"虚拟数字经纪人\" />\n <figcaption>图十七:虚拟数字经纪人</figcaption>\n</figure>\n\n<p>有了虚拟的经纪人,那么该讲解什么样的内容呢?VR 带看语音来自于经纪人,画面行为帧数据也来源于经纪人行为。此时,就需要通过算法层面去合成讲稿并生产对应的音频和序列帧数据。整体的架构如图十八所示,前端所需要支持的就是定义画面行为的序列帧数据格式规范,由 AI 团队的剧本服务和 NLG 服务去计算 LRC 文本讲稿和行为序列。然后,通过主控服务生成带讲稿音频虚拟经纪人视频并附带行为序列帧数据给前端"翻译"。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic18.png\" alt=\"AR 讲房架构\" />\n <figcaption>图十八:AR 讲房架构</figcaption>\n</figure>\n\n<p>因为涉及的点过多,更多的细节本文就不再详细讲解了。大家可以扫描图十九的二维码或访问 <a href=\"//open.realsee.com/ke/15XKMYpVwOw3R7j8/BoZqQK8KmaAtncxhvTYre9ztvW9D50zg/?v3=1\">珠江罗马嘉园东区 2 室 1 厅</a> 这套房源进行体验。总之,由于 WebSocket 双工实时性和前端序列帧数据抽象,VR 的整体体验变得更加智能化。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19left.png\" alt=\"体验二维码\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19right.png\" alt=\"入口位置\" /></div>\n </div>\n <figcaption>图十九:AR 讲房体验二维码</figcaption>\n</figure>\n\n<h2 id=\"面临的性能挑战及应对方案\"><a href=\"#面临的性能挑战及应对方案\" class=\"headerlink\" title=\"面临的性能挑战及应对方案\"></a>面临的性能挑战及应对方案</h2><p>在过去三年的 VR 看房及衍生业务研发中我们主要面临的性能瓶颈有两个:加载耗时和内存溢出。</p>\n<h3 id=\"加载耗时\"><a href=\"#加载耗时\" class=\"headerlink\" title=\"加载耗时\"></a>加载耗时</h3><p>在 2019 年 8 月份前,贝壳如视 VR 首屏加载平均耗时 7.6s,截至 2021 年 7 月份已经降至 1.92s,正常网络情况下用户基本无需等待过多时间去体验 VR 房源。如此巨大的提升我们究竟做了些什么呢?首先我们先分析之前慢的原因,然后"对症下药"。而且首屏的性能提升也不是一蹴而就的事情,我们内部成立了个性能体验专项虚拟团队持续了近一年才达到最终 1.92s 的效果。</p>\n<p>问题出在哪儿呢?主要在三个方面:</p>\n<h4 id=\"密集的-HTTP-请求\"><a href=\"#密集的-HTTP-请求\" class=\"headerlink\" title=\"密集的 HTTP 请求\"></a>密集的 HTTP 请求</h4><p>前文提到 VR 3D 模型依赖大量的模型 UV 贴图和全景图片;除此之外,还有大量的地图、讲房音视频等资源。在浏览器的限制下同个域下的 CDN 请求限制在 3~6 个(不同浏览器会有差异)。大量的网络请求只能排队等待。</p>\n<h4 id=\"实时计算\"><a href=\"#实时计算\" class=\"headerlink\" title=\"实时计算\"></a>实时计算</h4><p>前端存在大量的实时计算,比如 3D 模型文件的解压缩、户型图数据解析、三维空间分析及碰撞监测等。由于 JavaScript 的单进程,这些计算依赖也阻塞一些核心逻辑。</p>\n<h4 id=\"模块渲染加载策略不合理\"><a href=\"#模块渲染加载策略不合理\" class=\"headerlink\" title=\"模块渲染加载策略不合理\"></a>模块渲染加载策略不合理</h4><p>由于 VR 开发初期考虑不周全,我们的异步渲染加载策略设计并不合理,优先级策略划分错乱。</p>\n<p>分析原因后,优化策略就很明确了。针对密集的 HTTP 请求我们先添加更多 CDN 域名支持,保障同时刻的请求限制在五个以内并增加 HTTP2 协议支持。实时计算带来的耗时采取的策略是充分利用缓存(离线计算缓存、浏览器缓存以及服务端计算缓存等);同时,我们对模块渲染加载策略进行了重新设计,每个模块都规划好权重,按照权重来加载。此外,部分非核心交互则由用户触发后再加载渲染。由于历史包袱过重,真个过程持续了近一年,最终有了 7.6s 到 2.55s 的首屏加载的性能提升,过程如图二十(左)所示。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 3;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20left.png\" alt=\"耗时变化\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20right.gif\" alt=\"加载效果\" /></div>\n </div>\n <figcaption>图二十:VR 首屏性能提升过程</figcaption>\n</figure>\n\n<p>除上文提到的优化之外,我们还充分挖掘了部分客户端的能力。第一个能力是<strong>客户端 HTTP 请求拦截代理和缓存</strong>,通常情况下 WebView 缓存池"阈值"很低,而客户端缓存池则大得多;此外,分析对比来看客户端的 HTTP 请求效率要比 WebView 的 HTTP 请求高很多。支持 HTTP 请求代理和缓存之后,整个加载耗时降低了近 500ms。</p>\n<p>另外一个核心能力则是增加了<strong>客户端首屏渲染</strong>:即进入 VR 页面前客户端提前预载好首屏内容,在加载阶段展示客户端内容,等前端完成首屏渲染之后再换成前端的渲染效果。整个过程是无缝的,用户甚至感知不到加载过程,最终的效果如图二十(右)所示。</p>\n<h3 id=\"内存溢出\"><a href=\"#内存溢出\" class=\"headerlink\" title=\"内存溢出\"></a>内存溢出</h3><p>加载耗时现阶段已经取得比较好的效果,我们目前遭遇的最大的瓶颈是内存溢出。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic21.png\" alt=\"VR 内存占用\" />\n <figcaption>图二十一:VR 内存占用</figcaption>\n</figure>\n\n<p>在前文首屏优化中提到我们耗费大量的时间完善了模块加载渲染策略,因此在 VR 交互过程中,随着各个模块不断完成渲染,内存占用是逐步递增的,如图二十一(左)所示。在图二十一(右)扇形图中也列举了不同模块的内存占用情况。目前,iOS 设备的 WebView 内存崩溃的阈值大约在 1.5G 左右,Android 设备则不同机型阈值不完全一致,高端 Android 设备普遍比 iOS 设备高很多,但低端机阈值远低于 1.5G 内存。</p>\n<p>规避内存溢出问题我们从两个方向入手:</p>\n<h4 id=\"增加内存池\"><a href=\"#增加内存池\" class=\"headerlink\" title=\"增加内存池\"></a>增加内存池</h4><p>目前我们测试过 iOS/Android 设备各类 WebView 控件,除了实现 WebView 独立进程之外并没有找到突破 WebView 内存限制的方式。这个属于 WebView 容器瓶颈。</p>\n<h4 id=\"降低内存占用\"><a href=\"#降低内存占用\" class=\"headerlink\" title=\"降低内存占用\"></a>降低内存占用</h4><p>我们做了些突破,比如按需渲染,非可视区域销毁模块等等,但仅仅降低了崩溃率,成效并不明显。</p>\n<p>而且,随着业务的不断迭代,VR 能力愈来愈丰富,内存占用还在不断提升。依赖 WebView+WebGL+jsBridge 技术栈落地的 VR 体验现阶段有很明显的局限性,虽然纯原生技术栈已经提上日程但短期来看还是很难落地的。为了弱化内存溢出带来的影响,我们目前采取的策略是根据用户的使用场景以动态降级的方式给予用户最合适的交互体验。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic22.png\" alt=\"VR 性能瓶颈影响因素鱼骨图\" />\n <figcaption>图二十二:VR 性能瓶颈影响因素鱼骨图</figcaption>\n</figure>\n\n<p>性能优化的本质是渐进增强和优雅降级,把握每个细节把自己该做的部分做好一般都会有比较好的性能表现。我们系统分析了造成性能瓶颈各个因素,如图二十二所示。事实上,我们很难做些突破然后彻底解决内存问题,只能降级保障体验。</p>\n<p>如何做到更"智能"地渐进增强和优雅降级?首先需要的是前端支持模块的"热插拔"能力,即能动态的销毁某个模块以将内存空间给其他模块使用。此外,我们维护一个关于内存瓶颈的数据仓库,依托 WebSocket 的双工能力,VR 交互时会收集用户的终端设备信息及部分 VR 用户行为,并在实时分析该用户的终端的最大承受能力,推送给前端再动态地加载或卸载前端模块,从而达到加强体验或降级的效果。</p>\n<h2 id=\"总结\"><a href=\"#总结\" class=\"headerlink\" title=\"总结\"></a>总结</h2><p>前面给大家讲述了贝壳如视前端团队如何基于 VR 及 3D 技术在 Web 领域架构设计,并分享了在这个领域上的一些业务探索、实践及应对性能瓶颈的具体措施。本次的演讲的专题是"移动技术新趋势",最后站在技术的角度上做如下四个方面的经验(或趋势)总结来结束本次的演讲内容吧。</p>\n<h3 id=\"可玩性\"><a href=\"#可玩性\" class=\"headerlink\" title=\"可玩性\"></a>可玩性</h3><p>三维领域研发比传统基于 DOM 前端研发有趣得多,比如团队就有产品说过三维空间二次加工装修设计是更高阶的"乐高"式游戏,欢迎大家加入这个领域。</p>\n<h3 id=\"序列帧抽象及数据驱动\"><a href=\"#序列帧抽象及数据驱动\" class=\"headerlink\" title=\"序列帧抽象及数据驱动\"></a>序列帧抽象及数据驱动</h3><p>过往的前端交互都是用户主动触发的,但是在 3D 方向的交互模型更需要自动播放,提高信息获取的方式。前端数据层序列帧抽象,支持数据驱动、序列化和反序列化将是不可或缺的一环。</p>\n<h3 id=\"quot-热插拔-quot\"><a href=\"#quot-热插拔-quot\" class=\"headerlink\" title=\""热插拔"\"></a>"热插拔"</h3><p>3D 领域开发内存占用是远大于传统前端页面的,尤其在终端设备 WebView 容器下内存限制更明显。模块、组件及插件等封装都需要支持"热插拔",从而做到动态加强体验或降级的效果。</p>\n<h3 id=\"WebSocket\"><a href=\"#WebSocket\" class=\"headerlink\" title=\"WebSocket\"></a>WebSocket</h3><p>我们已经逐步在抛弃主动式 Ajax,数据的实时性和智能化都依赖 WebSocket 的双工能力。目前,WebSocket 服务已经是核心基础建设。</p>\n","site":{"data":{}},"excerpt":"","more":"<blockquote>\n<p>本文基于 2021 年 GMTC 全球大前端技术大会"移动技术新趋势"专题下主题分享<a href=\"//gmtc.infoq.cn/2021/beijing/presentation/3531\">《VR 及 3D 技术在 Web 端架构设计与实践》</a>整理而来。内容与当日分享基本无异,仅以文字的形式重新整理一遍。</p>\n</blockquote>\n<style type=\"text/css\">\narticle .ref {display: flex;line-height: 1;border: none;border-radius: 4px;padding: 12px;margin: 12px 0 0;background-color: rgb(239 236 236);border-bottom:none;font-family: Roboto, -apple-system, BlinkMacSystemFont, sans-serif;}article .ref:hover {background-color: rgb(228 223 223);}.ref-img {width: 56px;height: 56px;}.ref-content {padding: 0 0 0 12px;flex: 1;flex-direction: column;display: flex;min-width: 10px;}.ref-title, .ref-subTitle, .ref-link {overflow: hidden;white-space: nowrap;text-overflow: ellipsis;}.ref-title {font-size: 16px;font-weight: 500;line-height: 20px;color: rgb(31, 34, 37);}.ref-subTitle {line-height: 19px;}.ref-link {line-height: 17px;color: rgb(161, 162, 163);}\n.fancyboxflex .fancybox {flex: 1;}\n</style>\n<a class=\"ref\" href=\"//gmtc.infoq.cn/2021/beijing/presentation/3531\" target=\"_blank\">\n <img class=\"ref-img\" src=\"//solome.js.org/static/gmtc-vr3d/5c7caa3b1c540.jpeg\">\n <div class=\"ref-content\">\n <div class=\"ref-title\">GMTC_全球大前端技术大会-InfoQ</div>\n <div class=\"ref-subTitle\">\"GMTC是由极客邦科技和InfoQ中国主办的顶级技术盛会,关注移动、前端、AI应用等多个技术领域,促进全球技术交流,推动国内技术升级。GMTC为期4天,包括两天的会议和两天的培训课,主要面向各行业对移动开发、前端、AI技术感兴趣的中高端技术人员,大会聚焦前沿技术及实践经验,旨在帮助参会者了解移动开发&前端领域最新的技术趋势与最佳实践。\"</div>\n <div class=\"ref-link\">https://gmtc.infoq.cn/2021/beijing/presentation/3531</div>\n </div>\n</a>\n\n<p>VR 看房是 VR 及 3D 技术落地的场景之一,其特点是通过手机终端就能让人真正的置身其中,用自己直觉的空间感去感受整个房屋特征。本次分享将介绍贝壳如视前端团队是如何基于 VR 3D 模型进行前端架构设计的。除此之外,还将分享我们团队是如何基于 VR 看房能力探索新的业务形式以及面临的技术挑战。</p>\n<h2 id=\"基于-VR-3D-模型前端架构设计\"><a href=\"#基于-VR-3D-模型前端架构设计\" class=\"headerlink\" title=\"基于 VR 3D 模型前端架构设计\"></a>基于 VR 3D 模型前端架构设计</h2><p>在讲前端架构设计之前,先详细介绍下看房场景下的 VR 3D 模型的组成及形态。</p>\n<h3 id=\"看房-VR-3D-模型的组成及形态\"><a href=\"#看房-VR-3D-模型的组成及形态\" class=\"headerlink\" title=\"看房 VR 3D 模型的组成及形态\"></a>看房 VR 3D 模型的组成及形态</h3><p>房源的 VR 3D 模型的形态有多种,但在用户层面直观感受到的主要有三个形态:3D 模型形态、点位全景形态及 VR 眼镜视角形态。下面对这三个形态做详细介绍。</p>\n<h4 id=\"3D-模型\"><a href=\"#3D-模型\" class=\"headerlink\" title=\"3D 模型\"></a>3D 模型</h4><p>首先,我们简单思考一下三维模型是如何在二维平面抽象建模的?目前主流的三维模型抽象建模是基于多边形网格(Polygon Mesh),如图一所示。整体感知就是多边形面片愈多(面片密度)还原的三维立体效果愈真实。最精简的多边形自然是三角形(大部分场景下说的面片即三角面片),三维物体的每个细节可以通过三角面片的顶点、边及面等几何数学概念来描述。微观上来看,基于面片建模的三维模型本质上都是密度及其复杂的几何体。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic1.png\" alt=\"多边形网格模拟立体效果\" />\n <figcaption>图一:多边形网格模拟立体效果</figcaption>\n</figure>\n\n<p>因此,依赖一些专业 3D 扫描仪(比如如视自研的黎曼、伽罗华等扫描仪)或全景相机等设备采集数据后,再通过算法加工可以获取这些描述三维立体结构的三角面片数据。前端再利用 WebGL/Three.js 等技术将其渲染至浏览器上,此时我们能得到房源的三维立体轮廓,效果如图二(左)所示的网格模型。当然,图二(右)才是我们期望的效果,仅仅有三维"骨架"轮廓是不够的,我们需要在此基础上贴一层"皮肤",而这层"皮肤"则是通过 UV 纹理贴图添加上的。</p>\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2left.gif\" /></div>\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic2right.gif\" /></div>\n </div>\n <figcaption>图二:三角面片描述的三维效果</figcaption>\n</figure>\n\n<p>对于三维模型有两个比较重要的坐标系统,一个是顶点的位置<code>(x,y,z)</code>坐标,另一个则是 UV 坐标。什么是 UV 呢?简言之,就是二维平面贴图映射到三维模型表面的依据。比如典型的 UV 贴图效果如图三所示,刚刚前文提到三维结构是通过顶点、边及面组成的三角面片组成的,这个三角面是二维的,通过一些数据依赖映射关系从 UV 贴图中抠出一个相同边、面的三角形贴到三角面片上。所以,此处的 UV 即指定义了二维平面图片每个点的位置与三维结构三角面片位置的映射关系信息。作为前端工程师,这个跟前端雪碧图(Sprite)概念将多个图标合并成一张图的原理是一致的。</p>\n<figure>\n <img style=\"width: 35%\" src=\"//solome.js.org/static/gmtc-vr3d/pic3.png\" alt=\"房源UV贴图\" />\n <figcaption>图三:房源UV贴图</figcaption>\n</figure>\n\n<p>至此,基于三角面片和 UV 贴图数据我们成功渲染出了房源的 3D 模型。当然,出于性能考虑我们的三角面片密度不是特别高的,纯粹依靠 3D 模型在终端设备(iOS\\Android 等)还原房源的真实细节现阶段并不现实。三角面片少,数据量低,内存占用低,我们可以通过 3D 模型还原房源的整体结构。至于细节,则通过点位立方体全景的方式去实现。</p>\n<h4 id=\"点位全景\"><a href=\"#点位全景\" class=\"headerlink\" title=\"点位全景\"></a>点位全景</h4><p>前文提到房源的整体结构通过 3D 模型体现,至于细节则通过全景的形式来表现。我们会在房源选择多个合适的点位拍摄全景图片,然后以立方体全景的方式渲染以实现 720 º 环顾的效果,如图四(左)所示。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4left.gif\" alt=\"全景效果\" /></div>\n <div style=\"flex: 14;padding: 0 0 0 40px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic4right.png\" alt=\"全景贴图展开\" /></div>\n </div>\n <figcaption>图四:立方体全景效果及其展开</figcaption>\n</figure>\n\n<p>全景的实现是比较成熟的技术,主流的实现方式有立方体全景和球型全景。两种方式各有优缺点,由于立方体全景二次加工成本低如视目前以立方体全景技术实现为主。立方体全景的原理是渲染一个立方体盒子,给其上、下、前、后、左和右六个面各贴上一张图。需要注意的是,这六张图从中选择连续的四张图拼接在一起是一张连贯的全景图,如图四(右)所示 T 字形立方体贴图展开。此时,当人眼放置在立方体中心点观望四周是连贯的全景效果。</p>\n<p>全景的效果完全依赖贴图的清晰度,所以我们可以拍摄高清 2048 分辨率的全景图片去体现房源某个位置的细节信息。这也是看房 VR 3D 模型的第二个核心形态点位全景形态。</p>\n<h4 id=\"VR-眼镜全景\"><a href=\"#VR-眼镜全景\" class=\"headerlink\" title=\"VR 眼镜全景\"></a>VR 眼镜全景</h4><p>前文提到的 3D 模型和点位全景形态都是基于二维显示屏展现的(裸眼体验),如果想让用户具备身临其境的感觉往往需要依赖 VR 眼镜设备。针对这类设备我们需要适配<a href=\"//www.w3.org/TR/webxr/\">WebXR Device API</a>,我们现阶段的适配策略是渲染两个相同的点位立方体全景,分别供左右眼感知。最终适配的效果如图五所示。</p>\n<figure>\n <div style=\"flex: 1;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic5left.gif\" alt=\"眼镜全景\" /></div>\n <figcaption>图五:VR 眼镜全景</figcaption>\n</figure>\n\n<p>限于大部分用户的设备还是 iOS\\Android,目前的裸眼 VR 3D 体验是主流。随着硬件设备的推广,等到 VR 眼镜走向普通用户时,这种更具身临其境的体验会慢慢更多用户接触到。</p>\n<p>当然,除了本文提到 3D 模型形态、点位全景和 VR 眼镜全景三种形态之外,我们内部还有多种其他形态,如模型垂直视角、深度图渲染的全景视角等形态,但是偏技术领域且与普通用户感知不深,此处不详细介绍了。</p>\n<p>最后,基于这三种形态外加一个房源的二维户型图就组成了我们看房 VR 3D 模型的核心结构,在此基础不断完善各种交互(比如形态间切换补间 Tween 动画)、产品功能逐步演变成大家所熟悉的贝壳如视 VR 看房。</p>\n<blockquote>\n<p>演讲问答环节及后续的反馈情况来看,大家对分享提到的形态间切换的 Tween 动画实现比较感兴趣,且部分同行表示自己实现的效果达不到如视的移动真实感。此处细节较多,准备后续单独出文章分享,本文暂不花费篇幅详细介绍。</p>\n</blockquote>\n<h3 id=\"前端架构分层设计\"><a href=\"#前端架构分层设计\" class=\"headerlink\" title=\"前端架构分层设计\"></a>前端架构分层设计</h3><figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic6.png\" alt=\"前端架构分层设计\" />\n <figcaption>图六:前端架构分层设计</figcaption>\n</figure>\n\n<p>前文提到房源的 VR 3D 模型的组成及三个核心形态,我们实现了通过 3D 技术真实还原房源信息。经过多轮的产品需求迭代,我们在 VR 3D 模型的基础上不断地完善整个前端的架构分层设计。现阶段,整个 VR 用户端前端设计中我们抽象了三层:Web 服务层、前端数据层和 View 层。</p>\n<p>我们将 View 层划分成四个方向进行抽象,第一个方向是纯 DOM 层的,比如首屏内容、控制面板、信息面板等,这层我们通常以 React/Vue 组件进行抽象服用。第二个方向是基于 Canvas/WebGL 渲染的三维视图,其功能即前文提到的房源 VR 3D 模型交互。第三个方向是我们维护的 3D 插件生态,以 VR 3D 模型为基础且以插件的形式派生出新的交互、能力(比如,模型中的指南针、电视视频等均以插件的形式集成)。最后一个方向是协议层抽象,我们 VR 是通过 Web 前端技术渲染实现的,以 WebView 作为容器集成在终端 App 里面,通过 jsBridge 的方式实现双向通信。为了保障业务代码的统一性,我们将第三方依赖(jsBridge/RTC/WebSocket 等)进行一层协议抽象,以达到面向协议开发以抹平不同终端差异性的目的。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic7.png\" alt=\"数据序列帧抽象\" />\n <figcaption>图七:数据序列帧抽象</figcaption>\n</figure>\n\n<p>第二层是数据层的抽象。此处的数据并不是面向后端服务的数据层,而是前端 UI 交互的数据层抽象。我们将 UI 交互的状态以全局帧数据的形式抽象出来,当 UI 发生变化则同步至帧数据;当然,如果帧数据被发生改动(修改帧数据对象)则也会驱动 UI 发生相同变化。这个过程通过 JavaSciprt 中 Proxy 拦截数据对象实现的,如图七。换言之,UI 交互能产生新的帧数据,通过帧数据也能还原对应的 UI 状态。至于,为什么要花费大量精力做这个工作后文讲解业务部分时会有详细介绍。</p>\n<p>第三层 Web 层有两个方向的核心服务,其中基于 Node.js/Go 实现的 HTTP 服务主要提供 VR 页面的 HTML"壳子"和首屏数据,而基于 WebSocket 服务的全双工数据通道则保障了 VR 体验过程与后台服务的实时通讯。WebSocket 长链接技术有传统 HTTP 方式无可比拟的优势(协议私有、实时性高、性能优异等),对我们业务的智能化、性能体验提升等无可替代,下文描述业务探索和性能体验部分大家会有更深切的感知。</p>\n<p>贝壳如视用户端的前端设计大致如此,我们大部分核心业务如 VR 语言导览、VR 实时带看和 AR 讲房等都是基于此设计研发的。</p>\n<h3 id=\"基于-3D-模型与传统-DOM-开发的差异性对比\"><a href=\"#基于-3D-模型与传统-DOM-开发的差异性对比\" class=\"headerlink\" title=\"基于 3D 模型与传统 DOM 开发的差异性对比\"></a>基于 3D 模型与传统 DOM 开发的差异性对比</h3><p>作为一名工作频繁接触 3D 相关技术的研发工程师,经常被咨询基于 3D 模型研发与传统 DOM 开发的区别。与传统前端开发差异性是存在的,但是适应如下三点基本就迈入前端 3D 开发的门槛。</p>\n<h4 id=\"三维坐标系-vs-DOM-树\"><a href=\"#三维坐标系-vs-DOM-树\" class=\"headerlink\" title=\"三维坐标系 vs DOM 树\"></a>三维坐标系 vs DOM 树</h4><p>前端 DOM 树布局是基于 CSS 盒子模型和 Flex 布局,页面大部分布局都是基于此实现的,此外还有圣杯、双飞翼等经典布局体系。在二维层面依托强大的 CSS,前端布局是随心所欲的。但是放在三维空间,我们大部分时间都在跟坐标系及坐标系间切换打交道。</p>\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic8.png\" alt=\"三维建模坐标体系\" />\n <figcaption>图八:三维建模坐标体系</figcaption>\n</figure>\n\n<p>三维研发的首个门槛就是跟各种坐标系打交道,比如三维物体本身的坐标系(一般称呼为本地坐标系),一个三维空间会存在多个三维物体,如何放置这些三维物体则需要一个三维世界坐标系来定位。此外,三维空间的三维物体通常都是静止的,其移动、旋转等操作都是控制相机的移动来实现的(当然,相机也是一种特殊的三维物体),如图八所示。然而,我们终端设备的屏幕是二维的,相机作为一个"眼睛"将三维物体投影到二维屏幕上又涉及到平面坐标系、齐次坐标系等等。所以,如何理清这些坐标系的概念和坐标系间的相互转换是 3D 研发的首个门槛,搞清这些在日后的研发中就能做到"游刃有余"。</p>\n<h4 id=\"面向异步-Hooks-事件\"><a href=\"#面向异步-Hooks-事件\" class=\"headerlink\" title=\"面向异步 Hooks 事件\"></a>面向异步 Hooks 事件</h4><p>在处理三维模型行为交互体验时与传统前端还有个很明显的差异就是面临的异步细节要多得多。在 DOM 层面前端开发时,我们接触的异步事件主要集中在点击、触摸、滚动和 Ajax 异步请求等。但是在三维交互中,除此之外我们还频繁接触放大缩小、拖拽位移、模式切换等各类异步行为。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 5;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9left.gif\" alt=\"全景走点效果\" /></div>\n <div style=\"flex: 3;padding: 0 0 0 10px; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic9right.png\" alt=\"涉及异步hooks事件\" /></div>\n </div>\n <figcaption>图九:点位全景切换走点</figcaption>\n</figure>\n\n<p>在如视内部的底层渲染引擎中,我们维护了比较完善的异步 Hooks 事件集来应对各种场景的交互行为。比如,如图九(左)效果是我们常见的 VR 房源点位全景交互走点移动,整个过程触发了九个异步事件回调,如图九(右)所列。这些回调将整个过程的细节全部暴露出来,方便研发人员更精准地把控体验。一般的终端工程师很难体验这种交互层面细维度精准把控的开发体验,初次接触需要适应。</p>\n<h4 id=\"碰撞检测\"><a href=\"#碰撞检测\" class=\"headerlink\" title=\"碰撞检测\"></a>碰撞检测</h4><p>最后一个比较明显的差异性是三维空间里面的碰撞监测。</p>\n<figure>\n <img style=\"width: 50%;min-width: 200px;\" src=\"//solome.js.org/static/gmtc-vr3d/pic10.gif\" alt=\"物体间遮挡与重叠\" />\n <figcaption>图十:物体间遮挡与重叠</figcaption>\n</figure>\n\n<p>如图十所示,在三维空间中摆置新物体难免会涉及遮盖、重叠的情况。在实际开发中,我们尽量规避这种现象的发生。碰撞监测常规的做法是针对物体创建一个规则的立体几何外形将其包围然后分析是否有重叠的部分;还有种思路是建立一条射线,获取此射线与两个物体间的焦点然后分析是否重合。<br>碰撞监测在不同的场景一般会采用合适的方式,对于移动的物体,有时候我们还需要在建模体系中添加物理引擎的支持。碰撞检测在不同的业务场景下,检测的策略是不同的,这个比较考验研发对整个三维空间的理解能力,本文就不展开更细节的内容了。</p>\n<h2 id=\"新型业务场景探索与实践\"><a href=\"#新型业务场景探索与实践\" class=\"headerlink\" title=\"新型业务场景探索与实践\"></a>新型业务场景探索与实践</h2><p>前文涉及的都是偏技术领域的,下面向大家分享下在已有的技术储备下,如视是如何在业务上做的一些探索与实践的。</p>\n<h3 id=\"三维空间分析计算与二次加工\"><a href=\"#三维空间分析计算与二次加工\" class=\"headerlink\" title=\"三维空间分析计算与二次加工\"></a>三维空间分析计算与二次加工</h3><figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic11.gif\" alt=\"物体(家具)识别\" />\n <figcaption>图十一:物体(家具)识别</figcaption>\n</figure>\n\n<p>三维模型是来源于现实真实的房源(通过专业设备拍摄及算法分析获取),我们可以对三维模型进行分析并将里面的家具物体识别出来(如图十一所示)。识别出这些物体后我们就能做些有趣的事情了,比如识别出显示器或电视,可以在此处添加一个视频播放广告或节目来营造更加真实的 3D 场景,效果如图十二(左)。识别平滑地面,我们可以放置一个扫地机器人或 3D 宝箱来做些营销活动等等,效果如图十二(中)、(右)。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12left.gif\" alt=\"电视视频\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12center.gif\" alt=\"扫地机器人\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;transform: scale(0.9153225806451613);transform-origin: left;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic12right.gif\" alt=\"宝箱营销\" /></div>\n </div>\n <figcaption>图十二:根据物体识别添加动态内容</figcaption>\n</figure>\n\n<p>除了空间内的物体识别之外,户型图也是我们二次加工的重点方向。比如,我们将二手房源里面家具及装修物体全部清理掉,然后就得到一个及其"纯净"的白模模型;在基于原有的户型结构重新规划将一个两室一厅的房源改造成一个三室一厅的房源,然后再重新加工装修风格和摆置家居物体等。</p>\n<p>整个过程,如图十三(左)所示,经历了从真实复杂的普通房源到简洁的白模再到复杂的新装修家居风格过程,给潜在的购房用户提前示例这套房源的改造空间。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 7;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13left.png\" alt=\"加工过程\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic13right.gif\" alt=\"一键切换\" /></div>\n </div>\n <figcaption>图十三:真实房源的二次加工</figcaption>\n</figure>\n\n<p>此外,我们在技术体验上也做了些突破,在终端层面实现真实房源与设计房源一键切换和同屏对比的交互体验,最终效果如图十三(右)所示。</p>\n<h3 id=\"VR-实时带看:同屏连线,高效看房\"><a href=\"#VR-实时带看:同屏连线,高效看房\" class=\"headerlink\" title=\"VR 实时带看:同屏连线,高效看房\"></a>VR 实时带看:同屏连线,高效看房</h3><p>另外一个业务场景探索则是线上 VR 实时带看能力的落地。首先,解释下为什么要往这个方向探索?大家有过买房或租房体验的都知道,大部分场景都是经纪人开车载着你去实地看房,一天下来也就看几套房源可能还要爬楼梯、等红绿灯或被太阳曝晒等意外情况。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 4;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14left.png\" alt=\"3D 交互与二维交互对比\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right1.gif\" alt=\"VR 同屏1\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 10px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic14right2.gif\" alt=\"VR 同屏2\" /></div>\n </div>\n <figcaption>图十四:3D 交互与二维交互对比及 VR 同屏</figcaption>\n</figure>\n\n<p>尽管 VR 房源虽然还原了房源的真实场景,但是三维空间交互还是比较复杂的,需要用户去探索细节。如图十四(左)是经典的信息流布局:搜索 ➙ 导航 ➙ 推荐 ➙ 筛选 ➙ 列表,这是二维最高效的信息展示布局,国内绝大部分提供数据服务的 App(电商京东、餐饮美团、房产贝壳等)均是这类布局。</p>\n<p>但是三维空间交互就没有这么明确了,全景只能查看当前点位且全景游走大部分用户并不知晓。此外,诸如房源的小区信息和附近学校、医院等信息也无法在 VR 3D 模型中明确体现。因此,我们实现了由用户无目的的在 VR 3D 模型中漫游、探索信息转向专业由经纪人带领画面同步、实时语言讲解。</p>\n<p>前文提到我们将前端所有的交互以序列帧数据的形式进行了抽象,用户交互会产生帧数据然后通过 WebSocket 将生成的帧数据同步给另外一个用户来驱动另外一个用户画面的更新。语音的话目前 RTC 技术比较成熟,我们落地即可,效果如图十四(右)所示。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic15.png\" alt=\"终端App与微信小程序VR 实时带看通道链路\" />\n <figcaption>图十五:终端App与微信小程序VR 实时带看通道链路</figcaption>\n</figure>\n\n<p>除了端与端 VR 带看之外,我们还实现终端 App(iOS/Android)与微信小程序的 VR 实时语音带看的业务能力,整个链路通道如图十五所示。</p>\n<p>线上 VR 实时带看能力在 2018 年底我们就已经初步实现落地,由于 2020 年新冠疫情影响造成大批潜在购房用户和经纪人居家隔离,线上 VR 实时带看目前已经成为了看房业务的核心场景。</p>\n<h3 id=\"VR-智能讲房:智能解说,身临其境\"><a href=\"#VR-智能讲房:智能解说,身临其境\" class=\"headerlink\" title=\"VR 智能讲房:智能解说,身临其境\"></a>VR 智能讲房:智能解说,身临其境</h3><p>前面提到 VR 带看是通过专业的经纪人陪同去了解房源解决 VR 3D 看房获取信息的方式不高效问题。但这个业务场景也存在些许缺陷:</p>\n<ul>\n<li>人力成本:经纪人不一定能及时响应,比如深夜休息时段。</li>\n<li>专业水平:不能保障经纪人对所有的房源都了解,又诸如方言等沟通效率。</li>\n</ul>\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic16.gif\" alt=\"社交恐惧症\" />\n <figcaption>图十六:“社交恐惧症”:客户不愿跟陌生人沟通</figcaption>\n</figure>\n\n<ul>\n<li>顾客“社交恐惧症”:不是人人都愿意跟陌生人沟通等。</li>\n</ul>\n<p>鉴于此,我们尝试把 VR 3D 交互做得更智能些。怎么做才更智能呢?首先,我们得不完全依赖真实的经纪人。我们将真实的经纪人形象和音色采集出来然后通过视频拼接和语言 TTS 服务来抽象出一个虚拟经纪人,并将此虚拟经纪人形象搬到用户的终端屏幕上,如图十七所示。</p>\n<figure>\n <img style=\"width: 50%;\" src=\"//solome.js.org/static/gmtc-vr3d/pic17.gif\" alt=\"虚拟数字经纪人\" />\n <figcaption>图十七:虚拟数字经纪人</figcaption>\n</figure>\n\n<p>有了虚拟的经纪人,那么该讲解什么样的内容呢?VR 带看语音来自于经纪人,画面行为帧数据也来源于经纪人行为。此时,就需要通过算法层面去合成讲稿并生产对应的音频和序列帧数据。整体的架构如图十八所示,前端所需要支持的就是定义画面行为的序列帧数据格式规范,由 AI 团队的剧本服务和 NLG 服务去计算 LRC 文本讲稿和行为序列。然后,通过主控服务生成带讲稿音频虚拟经纪人视频并附带行为序列帧数据给前端"翻译"。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic18.png\" alt=\"AR 讲房架构\" />\n <figcaption>图十八:AR 讲房架构</figcaption>\n</figure>\n\n<p>因为涉及的点过多,更多的细节本文就不再详细讲解了。大家可以扫描图十九的二维码或访问 <a href=\"//open.realsee.com/ke/15XKMYpVwOw3R7j8/BoZqQK8KmaAtncxhvTYre9ztvW9D50zg/?v3=1\">珠江罗马嘉园东区 2 室 1 厅</a> 这套房源进行体验。总之,由于 WebSocket 双工实时性和前端序列帧数据抽象,VR 的整体体验变得更加智能化。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 1;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19left.png\" alt=\"体验二维码\" /></div>\n <div style=\"flex: 2; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic19right.png\" alt=\"入口位置\" /></div>\n </div>\n <figcaption>图十九:AR 讲房体验二维码</figcaption>\n</figure>\n\n<h2 id=\"面临的性能挑战及应对方案\"><a href=\"#面临的性能挑战及应对方案\" class=\"headerlink\" title=\"面临的性能挑战及应对方案\"></a>面临的性能挑战及应对方案</h2><p>在过去三年的 VR 看房及衍生业务研发中我们主要面临的性能瓶颈有两个:加载耗时和内存溢出。</p>\n<h3 id=\"加载耗时\"><a href=\"#加载耗时\" class=\"headerlink\" title=\"加载耗时\"></a>加载耗时</h3><p>在 2019 年 8 月份前,贝壳如视 VR 首屏加载平均耗时 7.6s,截至 2021 年 7 月份已经降至 1.92s,正常网络情况下用户基本无需等待过多时间去体验 VR 房源。如此巨大的提升我们究竟做了些什么呢?首先我们先分析之前慢的原因,然后"对症下药"。而且首屏的性能提升也不是一蹴而就的事情,我们内部成立了个性能体验专项虚拟团队持续了近一年才达到最终 1.92s 的效果。</p>\n<p>问题出在哪儿呢?主要在三个方面:</p>\n<h4 id=\"密集的-HTTP-请求\"><a href=\"#密集的-HTTP-请求\" class=\"headerlink\" title=\"密集的 HTTP 请求\"></a>密集的 HTTP 请求</h4><p>前文提到 VR 3D 模型依赖大量的模型 UV 贴图和全景图片;除此之外,还有大量的地图、讲房音视频等资源。在浏览器的限制下同个域下的 CDN 请求限制在 3~6 个(不同浏览器会有差异)。大量的网络请求只能排队等待。</p>\n<h4 id=\"实时计算\"><a href=\"#实时计算\" class=\"headerlink\" title=\"实时计算\"></a>实时计算</h4><p>前端存在大量的实时计算,比如 3D 模型文件的解压缩、户型图数据解析、三维空间分析及碰撞监测等。由于 JavaScript 的单进程,这些计算依赖也阻塞一些核心逻辑。</p>\n<h4 id=\"模块渲染加载策略不合理\"><a href=\"#模块渲染加载策略不合理\" class=\"headerlink\" title=\"模块渲染加载策略不合理\"></a>模块渲染加载策略不合理</h4><p>由于 VR 开发初期考虑不周全,我们的异步渲染加载策略设计并不合理,优先级策略划分错乱。</p>\n<p>分析原因后,优化策略就很明确了。针对密集的 HTTP 请求我们先添加更多 CDN 域名支持,保障同时刻的请求限制在五个以内并增加 HTTP2 协议支持。实时计算带来的耗时采取的策略是充分利用缓存(离线计算缓存、浏览器缓存以及服务端计算缓存等);同时,我们对模块渲染加载策略进行了重新设计,每个模块都规划好权重,按照权重来加载。此外,部分非核心交互则由用户触发后再加载渲染。由于历史包袱过重,真个过程持续了近一年,最终有了 7.6s 到 2.55s 的首屏加载的性能提升,过程如图二十(左)所示。</p>\n<figure>\n <div style=\"display:flex;\">\n <div style=\"flex: 3;display:flex;justify-content: center;align-items: center;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20left.png\" alt=\"耗时变化\" /></div>\n <div style=\"flex: 1; display:flex;justify-content: center;align-items: center;padding-left: 60px;padding-right: 40px;\"><img src=\"//solome.js.org/static/gmtc-vr3d/pic20right.gif\" alt=\"加载效果\" /></div>\n </div>\n <figcaption>图二十:VR 首屏性能提升过程</figcaption>\n</figure>\n\n<p>除上文提到的优化之外,我们还充分挖掘了部分客户端的能力。第一个能力是<strong>客户端 HTTP 请求拦截代理和缓存</strong>,通常情况下 WebView 缓存池"阈值"很低,而客户端缓存池则大得多;此外,分析对比来看客户端的 HTTP 请求效率要比 WebView 的 HTTP 请求高很多。支持 HTTP 请求代理和缓存之后,整个加载耗时降低了近 500ms。</p>\n<p>另外一个核心能力则是增加了<strong>客户端首屏渲染</strong>:即进入 VR 页面前客户端提前预载好首屏内容,在加载阶段展示客户端内容,等前端完成首屏渲染之后再换成前端的渲染效果。整个过程是无缝的,用户甚至感知不到加载过程,最终的效果如图二十(右)所示。</p>\n<h3 id=\"内存溢出\"><a href=\"#内存溢出\" class=\"headerlink\" title=\"内存溢出\"></a>内存溢出</h3><p>加载耗时现阶段已经取得比较好的效果,我们目前遭遇的最大的瓶颈是内存溢出。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic21.png\" alt=\"VR 内存占用\" />\n <figcaption>图二十一:VR 内存占用</figcaption>\n</figure>\n\n<p>在前文首屏优化中提到我们耗费大量的时间完善了模块加载渲染策略,因此在 VR 交互过程中,随着各个模块不断完成渲染,内存占用是逐步递增的,如图二十一(左)所示。在图二十一(右)扇形图中也列举了不同模块的内存占用情况。目前,iOS 设备的 WebView 内存崩溃的阈值大约在 1.5G 左右,Android 设备则不同机型阈值不完全一致,高端 Android 设备普遍比 iOS 设备高很多,但低端机阈值远低于 1.5G 内存。</p>\n<p>规避内存溢出问题我们从两个方向入手:</p>\n<h4 id=\"增加内存池\"><a href=\"#增加内存池\" class=\"headerlink\" title=\"增加内存池\"></a>增加内存池</h4><p>目前我们测试过 iOS/Android 设备各类 WebView 控件,除了实现 WebView 独立进程之外并没有找到突破 WebView 内存限制的方式。这个属于 WebView 容器瓶颈。</p>\n<h4 id=\"降低内存占用\"><a href=\"#降低内存占用\" class=\"headerlink\" title=\"降低内存占用\"></a>降低内存占用</h4><p>我们做了些突破,比如按需渲染,非可视区域销毁模块等等,但仅仅降低了崩溃率,成效并不明显。</p>\n<p>而且,随着业务的不断迭代,VR 能力愈来愈丰富,内存占用还在不断提升。依赖 WebView+WebGL+jsBridge 技术栈落地的 VR 体验现阶段有很明显的局限性,虽然纯原生技术栈已经提上日程但短期来看还是很难落地的。为了弱化内存溢出带来的影响,我们目前采取的策略是根据用户的使用场景以动态降级的方式给予用户最合适的交互体验。</p>\n<figure>\n <img src=\"//solome.js.org/static/gmtc-vr3d/pic22.png\" alt=\"VR 性能瓶颈影响因素鱼骨图\" />\n <figcaption>图二十二:VR 性能瓶颈影响因素鱼骨图</figcaption>\n</figure>\n\n<p>性能优化的本质是渐进增强和优雅降级,把握每个细节把自己该做的部分做好一般都会有比较好的性能表现。我们系统分析了造成性能瓶颈各个因素,如图二十二所示。事实上,我们很难做些突破然后彻底解决内存问题,只能降级保障体验。</p>\n<p>如何做到更"智能"地渐进增强和优雅降级?首先需要的是前端支持模块的"热插拔"能力,即能动态的销毁某个模块以将内存空间给其他模块使用。此外,我们维护一个关于内存瓶颈的数据仓库,依托 WebSocket 的双工能力,VR 交互时会收集用户的终端设备信息及部分 VR 用户行为,并在实时分析该用户的终端的最大承受能力,推送给前端再动态地加载或卸载前端模块,从而达到加强体验或降级的效果。</p>\n<h2 id=\"总结\"><a href=\"#总结\" class=\"headerlink\" title=\"总结\"></a>总结</h2><p>前面给大家讲述了贝壳如视前端团队如何基于 VR 及 3D 技术在 Web 领域架构设计,并分享了在这个领域上的一些业务探索、实践及应对性能瓶颈的具体措施。本次的演讲的专题是"移动技术新趋势",最后站在技术的角度上做如下四个方面的经验(或趋势)总结来结束本次的演讲内容吧。</p>\n<h3 id=\"可玩性\"><a href=\"#可玩性\" class=\"headerlink\" title=\"可玩性\"></a>可玩性</h3><p>三维领域研发比传统基于 DOM 前端研发有趣得多,比如团队就有产品说过三维空间二次加工装修设计是更高阶的"乐高"式游戏,欢迎大家加入这个领域。</p>\n<h3 id=\"序列帧抽象及数据驱动\"><a href=\"#序列帧抽象及数据驱动\" class=\"headerlink\" title=\"序列帧抽象及数据驱动\"></a>序列帧抽象及数据驱动</h3><p>过往的前端交互都是用户主动触发的,但是在 3D 方向的交互模型更需要自动播放,提高信息获取的方式。前端数据层序列帧抽象,支持数据驱动、序列化和反序列化将是不可或缺的一环。</p>\n<h3 id=\"quot-热插拔-quot\"><a href=\"#quot-热插拔-quot\" class=\"headerlink\" title=\""热插拔"\"></a>"热插拔"</h3><p>3D 领域开发内存占用是远大于传统前端页面的,尤其在终端设备 WebView 容器下内存限制更明显。模块、组件及插件等封装都需要支持"热插拔",从而做到动态加强体验或降级的效果。</p>\n<h3 id=\"WebSocket\"><a href=\"#WebSocket\" class=\"headerlink\" title=\"WebSocket\"></a>WebSocket</h3><p>我们已经逐步在抛弃主动式 Ajax,数据的实时性和智能化都依赖 WebSocket 的双工能力。目前,WebSocket 服务已经是核心基础建设。</p>\n"},{"title":"JavaScript 異步編程小結","date":"2016-12-16T07:18:22.000Z","comments":1,"_content":"\nJavaScript 是單線程的,除了你的 JS 代码,其它操作都是并行执行的(everything runs in parallel except your code)。\n\n在 JS 執行線程中進行的行為被稱作同步(Synchronous)操作,非 JS 執行線程執行的行為則被稱呼為異步(Asynchronous)操作。\n諸如 Ajax/HTTP 請求、I/O 操作等行為均與 JS 執行線程無關(由自己獨立的線程進行運作),這些行為在執行完成之後會將結果通知到 JS 執行線程;\n因此,JS 執行線程中會有個類似`while(true)`的循環,以觀察者的姿態`監聽`(轮询)是否有其它線程傳遞消息過來,一旦捕獲到則執行本 JS 執行線程中相應的函數塊(回調)。\n\nJavaScript 事件循環不是本文的重點([JavaScript Event Loop](https://docs.google.com/presentation/d/1-UC3cwd0KZtdSRAd6edLD-CvrOeM-IOpJYcb8rhElBY/edit?usp=sharing)),本文僅對前端異步編程進行些許總結。個人的理解是 JavaScript 異步編程方式只有兩種方式:回調和觀察者模式。需要注意的是:\n\n- Promises/A+ 是如何優雅地使用回調而設計的一種編程規範,本質依舊是回調\n- 事件監聽和觀察者模式(發佈/訂閱模式)完全可以理解成是“一個孩子的不同暱稱”\n- Generators 是一種特性,實現函數在執行過程中暫停、並在將來的某個時刻恢復執行的功能\n- Generators+Promises 可以搭配漂亮的語法糖,將異步源碼寫得像同步源碼\n\n### Callback Functions\n\n函數式編程中有個概念叫做高階函數(Higher-order Functions),其有個特性是一個函數可以作為另外一個函數的參數。通常我們將那個作為另外一個函數參數的函數稱呼為回調函數。\n\n為方便描述和解釋,此處模擬一個具體的業務場景:通過 Ajax 方式請求`**/api/v1.0/user/{id}`接口獲取某個用戶的信息(Asynchronous behavior),然後針對拿到的用戶信息進行後續的處理。\n典型的做法是將`Ajax`異步請求之後進行的操作封裝成`callback()`函數,在接口訪問成功得到用戶信息之後再執行該函數:\n\n```js\nfunction getUserInfoCallback(id, callback) {\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: data => callback(null, data),\n error: (xhr, textStatus, errorThrown)\n => callback(new Error(textStatus), errorThrown),\n })\n}\ngetUserInfoCallback('10086', handleUserInfo)\n```\n\n### Promises/A+\n\n> An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.\n\n濫用回調帶來的問題是代碼邏輯耦合度很高,面臨回調災難。Promises/A+是種合理使用回調的**規範**,避免回調的濫用。\n\n#### 特點一:提供好看的 API,由嵌套回調([callback hell](http://callbackhell.com/))轉向鏈式語法\n\n首先將請求用戶信息的 Ajax 異步操作包裝成一個 Promise 實例,後續的同步行為通過該實例對象的`then()`方法調用。\n\n```js\nfunction getUserInfoPromise(id) {\n return new Promise((fulfill, reject) => {\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: fulfill,\n error: reject,\n }) // end $.ajax\n }) // end return\n} // end getUserInfoPromise\n\ngetUserInfoPromise('10086')\n .then((userInfo) => handleUserInfo)\n .catch(console.log)\n```\n\n#### 特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果\n\n假設存在這樣的一個業務場景:獲取用戶 id 為`10086`的用戶信息,然後在不同的兩個階段對其異步操作獲取的用戶信息進行兩種不同的操作(分別為`handleUserInfo()`和`console.log()`)。\n在兩個階段中,都需要異步操作獲取得到的`userInfo`數據,如果採用傳統回調方式,一般採用閉包的方式緩存`userInfo`或者暴力點重複進行一次 Ajax 異步請求。\n\n但是採用 Promise 方式,則無需這些很**複雜**的實現方式,因為可以重複使用 Promise 對象。\n\n```js\n// 閉包緩存\nlet globUserInfo = null\ngetUserInfoCallback('10086', (userInfo) => (globUserInfo = userInfo))\n// 難以保證 globUserInfo 已經更新\nhandleUserInfo(globUserInfo)\nconsole.log(globUserInfo)\n\n// 進行了兩次異步操作\ngetUserInfoCallback('10086', handleUserInfo)\ngetUserInfoCallback('10086', console.log)\n\n// 保存Promise對象\nconst userInfoPromise = getUserInfoPromise('10086')\nuserInfoPromise.then(handleUserInfo)\n// 可以再次使用`userInfoPromise`對象\nuserInfoPromise.then(console.log)\n```\n\n- 這種策略和函數式編程中[lazy evaluation](https://en.wikipedia.org/wiki/Lazy_evaluation)概念是類似的,強調`call-by-need`。\n- 也可以對異步操作進行柯裡化([Curring](https://en.wikipedia.org/wiki/Currying))暫存異步操作的結果(類似的概念還有 thunk,參考[node-thunkify](https://github.com/tj/node-thunkify))。\n\n#### 特點三:可組合,復用\n\n類似於函數式編程中推廣的從已有的函數中創建新函數,也可以通過已有的 Promise 對象生成新的 Promise 對象。\n比如獲取多個用戶信息,可使用`Promise.all()`方法實現異步操作的組合:\n\n```js\nconst promises = ['10010', '10086', '10000'].map(\n (id) =>\n new Promise((fulfill, reject) =>\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: fulfill,\n error: reject,\n })\n )\n)\nconst userInfosPromise = Promise.all(promises).then(console.log).catch(console.log)\n```\n\n> 感覺上 Promise/A+規範是函數式編程概念在前端領域的一次最佳實踐(回調的語法糖)。更多詳細的內容待補充。\n\n### Event Emitters\n\n事件監聽式異步編程本質上還是依賴於回調函數實現的,區別在於回調函數並不執行異步行為完成後需要的操作,而是發佈一個通知去觸發執行相應的函數。\n\n```js\nimport EventEmitter from 'events'\nconst emitter = new EventEmitter()\n// 註冊\nemitter.on('event', handleUserInfo)\n$.ajax({\n url: `**/api/v1.0/user/10086`,\n success: (data) => emitter.emit('event', data), // 觸發:異步操作這個行為帶來的影響\n error: console.log,\n})\n```\n\n事件監聽其實是觀察者模式的一種實現:當一個對象發生變化時,所有依賴他的相關操作都會得到通知,只不過事件監聽弱化了對象的變化而強調行為(對象數據變更也是一種行為)。\n比如上面的代碼段強調的是 Ajax 操作這個行為,一旦完成就**通知**到`handleUserInfo()`函數的調用,並攜帶參數變更對象數據。\n\n如果採用觀察者模式的話,一般這樣直接處理數據(強調數據變化帶來的影響,造成數據變化的場景可能存在多處),然後觸發數據變動後的行為:\n\n```js\nlet userInfo = null\nemitter.on('event', () => handleUserInfo(userInfo))\nconst updateUserInfo = data => {\n userInfo = data // userInfo對象方式變更\n emitter.emit('event') // 通知相關依賴的操作:數據變更帶來的影響\n}\n$.ajax(\n url: `**/api/v1.0/user/10086`,\n success: updateUserInfo, // 觸發\n error: console.log,\n})\n```\n\n很明顯,觀察者模式要比事件監聽方式擴充性更強(雖然本質一致,但是強調側重點不同)。\n\n<figure style=\"padding-top: 0;margin-top: .5em;\">\n <img src=\"//solome.js.org/static/javascript-async/pingpong.gif\" />\n <figcaption>陷入`emit`死循環</figcaption>\n</figure>\n\n事件監聽式異步編程無異於`goto`語句,稍有不慎形如`on()`、`emit()`、`subscribe()`、`publish()`等方法摻雜在各處,“剪不清,理還亂”;如果不是“約定”化編程不建議採用。比如下面這段源碼,稍不慎就陷入如圖 1 所示場景。\n\n```js\nconst emitter = new EventEmitter()\nconst foo = () => emitter.emit('bar')\nconst bar = () => emitter.emit('foo')\nemitter.on('foo', foo)\nemitter.on('bar', bar)\nfoo() // 陷入死循環\n```\n\n和回調式異步編程(包括 Promises/A+規範)相比,事件監聽式異步編程的軟肋在於需要手動註冊(Manual)。\n原本可以通過數據綁定(Data binding)[Object.observe()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe)方法來實現觀察者模式,很可惜該方法已被`deprecated`掉;目前推薦的是`get`和`set`+[Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)方式實現(相關討論:[36258502](http://stackoverflow.com/questions/36258502/why-object-observe-has-been-deprecated))。\n\n但是手動維護這些`on()`、`emit()`、`get()`、`set()`等方法在項目是很折騰的,通過一些第三方工具包可以實現由`Manual`到`Automatic`轉變。\n比如採用[MobX](http://mobxjs.github.io/mobx/index.html)可以實現得更加優雅:\n\n```js\nimport { observable, autorun } from 'mobx'\nconst store = observable({userInfo: null})\n// 只要變動`store`對象,就會自動觸發`handleUserInfo()`函數\nautorun(() => handleUserInfo(store.userInfo))\n$.ajax(\n url: `**/api/v1.0/user/10086`,\n success: data => store.userInfo = data,\n error: console.log,\n})\n```\n\n### Generator\n\n#### [Coroutine](https://en.wikipedia.org/wiki/Coroutine) 協程 (a.k.a. co-operative routines)\n\n一般程序中,函數調用一定是從頭到尾執行直到遇到`return`或執行完;\n而 coroutine 則容許函數執行到一半時就中斷(yield),中斷時函數內部上下文環境(context)會被緩存下來。\n程序主體可以隨時恢復(resume)這個被緩存的 coroutine,繼續從剛才被中斷處執行後續內容。\n\n```js\nfunction* foo() {\n console.log('hello')\n yield 10086 // 在此處中斷 coroutine\n console.log('world')\n}\n\nconst bar = foo() // 保存 coroutine 內部狀態的變量\nbar.next() // 調用`foo()`函數,遇到 yield 中斷程序調用\nconsole.log('main, not in `foo()`') // 已經從`foo()`函數中跳出來了,可以幹些其它事情\nbar.next() // 恢復`foo()`的調用,從 yield 中斷處繼續執行\n```\n\n#### Thread VS Coroutine\n\n> With threads, the operating system switches running threads preemptively according to its scheduler,\n> which is an algorithm in the operating system kernel.\n> With coroutines, the programmer and programming language determine when to switch coroutines;\n> in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points,\n> typically (but not necessarily) within a single thread.<br />\n> —— [stackoverflow: difference-between-a-coroutine-and-a-thread](http://stackoverflow.com/questions/1934715/difference-between-a-coroutine-and-a-thread)\n\n#### [Generator](<https://en.wikipedia.org/wiki/Generator_(computer_programming)>) (a.k.a. semicoroutines) VS Coroutine\n\nGenerator 與 Coroutine 的區別是 Generator 只能從上次中斷處繼續執行,而 Coroutine 則沒有這樣的限制(可以指定從哪裡繼續執行)。\n因此,Generator 可以視作是 Coroutine 的一種特殊情況,上文涉及的源碼例子其實就是 Generator 的應用舉例。\n其中,Generator 涉及`bar.next()`自動流程管理的解決方案可以參考[co](https://github.com/tj/co)、[thunks](https://github.com/thunks/thunks)等。\n\n#### async/await “語法糖”\n\n聲明的`async`函數就是將 Generator 函數和自動執行器包裝在一個函數裡面(參考[async2generator()](https://gist.github.com/solome/064e48f5205943dff7d4918b3bf18e0d)),\n以達到異步編碼編程模式與同步編碼一致。\n\n```js\nconst run = async () => {\n const userInfo = await new Promise((fulfill, reject) => {\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: fulfill,\n error: reject,\n }) // end $.ajax\n }) // end return\n handleUserInfo(userInfo)\n}\n```\n\n### 不是總結的總結\n\n- 函數式編程領域的知識還是要多多接觸的。\n- 有些前端領域的新鮮事物在其他領域可能就是些習以為常的東西,擴充知識面很重要。\n\n### References\n\n- [Callback Hell](http://callbackhell.com/): A guide to writing asynchronous JavaScript programs.\n- [Promises/A+](https://promisesaplus.com/): An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.\n- [promisejs.org](https://www.promisejs.org/): A website dedicated to promises in JavaScript.\n- [regenerator](https://facebook.github.io/regenerator/): Source transformer enabling ECMAScript 6 generator functions in JavaScript-of-today.\n- [ecmascript-asyncawait](https://github.com/tc39/ecmascript-asyncawait): Async/await for ECMAScript.\n- [whats-the-big-deal-with-generators](http://devsmash.com/blog/whats-the-big-deal-with-generators)\n- [Threads, Fibers & Coroutines](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4287.pdf)\n","source":"_posts/javascript-async.md","raw":"---\ntitle: 'JavaScript 異步編程小結'\ndate: 2016-12-16 15:18:22 +0800\ncomments: true\ncategories: 学习札記\n---\n\nJavaScript 是單線程的,除了你的 JS 代码,其它操作都是并行执行的(everything runs in parallel except your code)。\n\n在 JS 執行線程中進行的行為被稱作同步(Synchronous)操作,非 JS 執行線程執行的行為則被稱呼為異步(Asynchronous)操作。\n諸如 Ajax/HTTP 請求、I/O 操作等行為均與 JS 執行線程無關(由自己獨立的線程進行運作),這些行為在執行完成之後會將結果通知到 JS 執行線程;\n因此,JS 執行線程中會有個類似`while(true)`的循環,以觀察者的姿態`監聽`(轮询)是否有其它線程傳遞消息過來,一旦捕獲到則執行本 JS 執行線程中相應的函數塊(回調)。\n\nJavaScript 事件循環不是本文的重點([JavaScript Event Loop](https://docs.google.com/presentation/d/1-UC3cwd0KZtdSRAd6edLD-CvrOeM-IOpJYcb8rhElBY/edit?usp=sharing)),本文僅對前端異步編程進行些許總結。個人的理解是 JavaScript 異步編程方式只有兩種方式:回調和觀察者模式。需要注意的是:\n\n- Promises/A+ 是如何優雅地使用回調而設計的一種編程規範,本質依舊是回調\n- 事件監聽和觀察者模式(發佈/訂閱模式)完全可以理解成是“一個孩子的不同暱稱”\n- Generators 是一種特性,實現函數在執行過程中暫停、並在將來的某個時刻恢復執行的功能\n- Generators+Promises 可以搭配漂亮的語法糖,將異步源碼寫得像同步源碼\n\n### Callback Functions\n\n函數式編程中有個概念叫做高階函數(Higher-order Functions),其有個特性是一個函數可以作為另外一個函數的參數。通常我們將那個作為另外一個函數參數的函數稱呼為回調函數。\n\n為方便描述和解釋,此處模擬一個具體的業務場景:通過 Ajax 方式請求`**/api/v1.0/user/{id}`接口獲取某個用戶的信息(Asynchronous behavior),然後針對拿到的用戶信息進行後續的處理。\n典型的做法是將`Ajax`異步請求之後進行的操作封裝成`callback()`函數,在接口訪問成功得到用戶信息之後再執行該函數:\n\n```js\nfunction getUserInfoCallback(id, callback) {\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: data => callback(null, data),\n error: (xhr, textStatus, errorThrown)\n => callback(new Error(textStatus), errorThrown),\n })\n}\ngetUserInfoCallback('10086', handleUserInfo)\n```\n\n### Promises/A+\n\n> An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.\n\n濫用回調帶來的問題是代碼邏輯耦合度很高,面臨回調災難。Promises/A+是種合理使用回調的**規範**,避免回調的濫用。\n\n#### 特點一:提供好看的 API,由嵌套回調([callback hell](http://callbackhell.com/))轉向鏈式語法\n\n首先將請求用戶信息的 Ajax 異步操作包裝成一個 Promise 實例,後續的同步行為通過該實例對象的`then()`方法調用。\n\n```js\nfunction getUserInfoPromise(id) {\n return new Promise((fulfill, reject) => {\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: fulfill,\n error: reject,\n }) // end $.ajax\n }) // end return\n} // end getUserInfoPromise\n\ngetUserInfoPromise('10086')\n .then((userInfo) => handleUserInfo)\n .catch(console.log)\n```\n\n#### 特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果\n\n假設存在這樣的一個業務場景:獲取用戶 id 為`10086`的用戶信息,然後在不同的兩個階段對其異步操作獲取的用戶信息進行兩種不同的操作(分別為`handleUserInfo()`和`console.log()`)。\n在兩個階段中,都需要異步操作獲取得到的`userInfo`數據,如果採用傳統回調方式,一般採用閉包的方式緩存`userInfo`或者暴力點重複進行一次 Ajax 異步請求。\n\n但是採用 Promise 方式,則無需這些很**複雜**的實現方式,因為可以重複使用 Promise 對象。\n\n```js\n// 閉包緩存\nlet globUserInfo = null\ngetUserInfoCallback('10086', (userInfo) => (globUserInfo = userInfo))\n// 難以保證 globUserInfo 已經更新\nhandleUserInfo(globUserInfo)\nconsole.log(globUserInfo)\n\n// 進行了兩次異步操作\ngetUserInfoCallback('10086', handleUserInfo)\ngetUserInfoCallback('10086', console.log)\n\n// 保存Promise對象\nconst userInfoPromise = getUserInfoPromise('10086')\nuserInfoPromise.then(handleUserInfo)\n// 可以再次使用`userInfoPromise`對象\nuserInfoPromise.then(console.log)\n```\n\n- 這種策略和函數式編程中[lazy evaluation](https://en.wikipedia.org/wiki/Lazy_evaluation)概念是類似的,強調`call-by-need`。\n- 也可以對異步操作進行柯裡化([Curring](https://en.wikipedia.org/wiki/Currying))暫存異步操作的結果(類似的概念還有 thunk,參考[node-thunkify](https://github.com/tj/node-thunkify))。\n\n#### 特點三:可組合,復用\n\n類似於函數式編程中推廣的從已有的函數中創建新函數,也可以通過已有的 Promise 對象生成新的 Promise 對象。\n比如獲取多個用戶信息,可使用`Promise.all()`方法實現異步操作的組合:\n\n```js\nconst promises = ['10010', '10086', '10000'].map(\n (id) =>\n new Promise((fulfill, reject) =>\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: fulfill,\n error: reject,\n })\n )\n)\nconst userInfosPromise = Promise.all(promises).then(console.log).catch(console.log)\n```\n\n> 感覺上 Promise/A+規範是函數式編程概念在前端領域的一次最佳實踐(回調的語法糖)。更多詳細的內容待補充。\n\n### Event Emitters\n\n事件監聽式異步編程本質上還是依賴於回調函數實現的,區別在於回調函數並不執行異步行為完成後需要的操作,而是發佈一個通知去觸發執行相應的函數。\n\n```js\nimport EventEmitter from 'events'\nconst emitter = new EventEmitter()\n// 註冊\nemitter.on('event', handleUserInfo)\n$.ajax({\n url: `**/api/v1.0/user/10086`,\n success: (data) => emitter.emit('event', data), // 觸發:異步操作這個行為帶來的影響\n error: console.log,\n})\n```\n\n事件監聽其實是觀察者模式的一種實現:當一個對象發生變化時,所有依賴他的相關操作都會得到通知,只不過事件監聽弱化了對象的變化而強調行為(對象數據變更也是一種行為)。\n比如上面的代碼段強調的是 Ajax 操作這個行為,一旦完成就**通知**到`handleUserInfo()`函數的調用,並攜帶參數變更對象數據。\n\n如果採用觀察者模式的話,一般這樣直接處理數據(強調數據變化帶來的影響,造成數據變化的場景可能存在多處),然後觸發數據變動後的行為:\n\n```js\nlet userInfo = null\nemitter.on('event', () => handleUserInfo(userInfo))\nconst updateUserInfo = data => {\n userInfo = data // userInfo對象方式變更\n emitter.emit('event') // 通知相關依賴的操作:數據變更帶來的影響\n}\n$.ajax(\n url: `**/api/v1.0/user/10086`,\n success: updateUserInfo, // 觸發\n error: console.log,\n})\n```\n\n很明顯,觀察者模式要比事件監聽方式擴充性更強(雖然本質一致,但是強調側重點不同)。\n\n<figure style=\"padding-top: 0;margin-top: .5em;\">\n <img src=\"//solome.js.org/static/javascript-async/pingpong.gif\" />\n <figcaption>陷入`emit`死循環</figcaption>\n</figure>\n\n事件監聽式異步編程無異於`goto`語句,稍有不慎形如`on()`、`emit()`、`subscribe()`、`publish()`等方法摻雜在各處,“剪不清,理還亂”;如果不是“約定”化編程不建議採用。比如下面這段源碼,稍不慎就陷入如圖 1 所示場景。\n\n```js\nconst emitter = new EventEmitter()\nconst foo = () => emitter.emit('bar')\nconst bar = () => emitter.emit('foo')\nemitter.on('foo', foo)\nemitter.on('bar', bar)\nfoo() // 陷入死循環\n```\n\n和回調式異步編程(包括 Promises/A+規範)相比,事件監聽式異步編程的軟肋在於需要手動註冊(Manual)。\n原本可以通過數據綁定(Data binding)[Object.observe()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe)方法來實現觀察者模式,很可惜該方法已被`deprecated`掉;目前推薦的是`get`和`set`+[Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)方式實現(相關討論:[36258502](http://stackoverflow.com/questions/36258502/why-object-observe-has-been-deprecated))。\n\n但是手動維護這些`on()`、`emit()`、`get()`、`set()`等方法在項目是很折騰的,通過一些第三方工具包可以實現由`Manual`到`Automatic`轉變。\n比如採用[MobX](http://mobxjs.github.io/mobx/index.html)可以實現得更加優雅:\n\n```js\nimport { observable, autorun } from 'mobx'\nconst store = observable({userInfo: null})\n// 只要變動`store`對象,就會自動觸發`handleUserInfo()`函數\nautorun(() => handleUserInfo(store.userInfo))\n$.ajax(\n url: `**/api/v1.0/user/10086`,\n success: data => store.userInfo = data,\n error: console.log,\n})\n```\n\n### Generator\n\n#### [Coroutine](https://en.wikipedia.org/wiki/Coroutine) 協程 (a.k.a. co-operative routines)\n\n一般程序中,函數調用一定是從頭到尾執行直到遇到`return`或執行完;\n而 coroutine 則容許函數執行到一半時就中斷(yield),中斷時函數內部上下文環境(context)會被緩存下來。\n程序主體可以隨時恢復(resume)這個被緩存的 coroutine,繼續從剛才被中斷處執行後續內容。\n\n```js\nfunction* foo() {\n console.log('hello')\n yield 10086 // 在此處中斷 coroutine\n console.log('world')\n}\n\nconst bar = foo() // 保存 coroutine 內部狀態的變量\nbar.next() // 調用`foo()`函數,遇到 yield 中斷程序調用\nconsole.log('main, not in `foo()`') // 已經從`foo()`函數中跳出來了,可以幹些其它事情\nbar.next() // 恢復`foo()`的調用,從 yield 中斷處繼續執行\n```\n\n#### Thread VS Coroutine\n\n> With threads, the operating system switches running threads preemptively according to its scheduler,\n> which is an algorithm in the operating system kernel.\n> With coroutines, the programmer and programming language determine when to switch coroutines;\n> in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points,\n> typically (but not necessarily) within a single thread.<br />\n> —— [stackoverflow: difference-between-a-coroutine-and-a-thread](http://stackoverflow.com/questions/1934715/difference-between-a-coroutine-and-a-thread)\n\n#### [Generator](<https://en.wikipedia.org/wiki/Generator_(computer_programming)>) (a.k.a. semicoroutines) VS Coroutine\n\nGenerator 與 Coroutine 的區別是 Generator 只能從上次中斷處繼續執行,而 Coroutine 則沒有這樣的限制(可以指定從哪裡繼續執行)。\n因此,Generator 可以視作是 Coroutine 的一種特殊情況,上文涉及的源碼例子其實就是 Generator 的應用舉例。\n其中,Generator 涉及`bar.next()`自動流程管理的解決方案可以參考[co](https://github.com/tj/co)、[thunks](https://github.com/thunks/thunks)等。\n\n#### async/await “語法糖”\n\n聲明的`async`函數就是將 Generator 函數和自動執行器包裝在一個函數裡面(參考[async2generator()](https://gist.github.com/solome/064e48f5205943dff7d4918b3bf18e0d)),\n以達到異步編碼編程模式與同步編碼一致。\n\n```js\nconst run = async () => {\n const userInfo = await new Promise((fulfill, reject) => {\n $.ajax({\n url: `**/api/v1.0/user/${id}`,\n success: fulfill,\n error: reject,\n }) // end $.ajax\n }) // end return\n handleUserInfo(userInfo)\n}\n```\n\n### 不是總結的總結\n\n- 函數式編程領域的知識還是要多多接觸的。\n- 有些前端領域的新鮮事物在其他領域可能就是些習以為常的東西,擴充知識面很重要。\n\n### References\n\n- [Callback Hell](http://callbackhell.com/): A guide to writing asynchronous JavaScript programs.\n- [Promises/A+](https://promisesaplus.com/): An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.\n- [promisejs.org](https://www.promisejs.org/): A website dedicated to promises in JavaScript.\n- [regenerator](https://facebook.github.io/regenerator/): Source transformer enabling ECMAScript 6 generator functions in JavaScript-of-today.\n- [ecmascript-asyncawait](https://github.com/tc39/ecmascript-asyncawait): Async/await for ECMAScript.\n- [whats-the-big-deal-with-generators](http://devsmash.com/blog/whats-the-big-deal-with-generators)\n- [Threads, Fibers & Coroutines](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4287.pdf)\n","slug":"javascript-async","published":1,"updated":"2023-11-07T15:59:51.406Z","_id":"clooia2lx0003ln3ybppl4cvn","layout":"post","photos":[],"link":"","content":"<p>JavaScript 是單線程的,除了你的 JS 代码,其它操作都是并行执行的(everything runs in parallel except your code)。</p>\n<p>在 JS 執行線程中進行的行為被稱作同步(Synchronous)操作,非 JS 執行線程執行的行為則被稱呼為異步(Asynchronous)操作。<br>諸如 Ajax/HTTP 請求、I/O 操作等行為均與 JS 執行線程無關(由自己獨立的線程進行運作),這些行為在執行完成之後會將結果通知到 JS 執行線程;<br>因此,JS 執行線程中會有個類似<code>while(true)</code>的循環,以觀察者的姿態<code>監聽</code>(轮询)是否有其它線程傳遞消息過來,一旦捕獲到則執行本 JS 執行線程中相應的函數塊(回調)。</p>\n<p>JavaScript 事件循環不是本文的重點(<a href=\"https://docs.google.com/presentation/d/1-UC3cwd0KZtdSRAd6edLD-CvrOeM-IOpJYcb8rhElBY/edit?usp=sharing\">JavaScript Event Loop</a>),本文僅對前端異步編程進行些許總結。個人的理解是 JavaScript 異步編程方式只有兩種方式:回調和觀察者模式。需要注意的是:</p>\n<ul>\n<li>Promises/A+ 是如何優雅地使用回調而設計的一種編程規範,本質依舊是回調</li>\n<li>事件監聽和觀察者模式(發佈/訂閱模式)完全可以理解成是“一個孩子的不同暱稱”</li>\n<li>Generators 是一種特性,實現函數在執行過程中暫停、並在將來的某個時刻恢復執行的功能</li>\n<li>Generators+Promises 可以搭配漂亮的語法糖,將異步源碼寫得像同步源碼</li>\n</ul>\n<h3 id=\"Callback-Functions\"><a href=\"#Callback-Functions\" class=\"headerlink\" title=\"Callback Functions\"></a>Callback Functions</h3><p>函數式編程中有個概念叫做高階函數(Higher-order Functions),其有個特性是一個函數可以作為另外一個函數的參數。通常我們將那個作為另外一個函數參數的函數稱呼為回調函數。</p>\n<p>為方便描述和解釋,此處模擬一個具體的業務場景:通過 Ajax 方式請求<code>**/api/v1.0/user/{id}</code>接口獲取某個用戶的信息(Asynchronous behavior),然後針對拿到的用戶信息進行後續的處理。<br>典型的做法是將<code>Ajax</code>異步請求之後進行的操作封裝成<code>callback()</code>函數,在接口訪問成功得到用戶信息之後再執行該函數:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">getUserInfoCallback</span>(<span class=\"hljs-params\">id, callback</span>) </span>{<br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: <span class=\"hljs-function\"><span class=\"hljs-params\">data</span> =></span> callback(<span class=\"hljs-literal\">null</span>, data),<br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-function\">(<span class=\"hljs-params\">xhr, textStatus, errorThrown</span>)</span><br><span class=\"hljs-function\"> =></span> callback(<span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Error</span>(textStatus), errorThrown),<br> })<br>}<br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, handleUserInfo)<br></code></pre></td></tr></table></figure>\n\n<h3 id=\"Promises-A\"><a href=\"#Promises-A\" class=\"headerlink\" title=\"Promises/A+\"></a>Promises/A+</h3><blockquote>\n<p>An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.</p>\n</blockquote>\n<p>濫用回調帶來的問題是代碼邏輯耦合度很高,面臨回調災難。Promises/A+是種合理使用回調的<strong>規範</strong>,避免回調的濫用。</p>\n<h4 id=\"特點一:提供好看的-API,由嵌套回調-callback-hell-轉向鏈式語法\"><a href=\"#特點一:提供好看的-API,由嵌套回調-callback-hell-轉向鏈式語法\" class=\"headerlink\" title=\"特點一:提供好看的 API,由嵌套回調(callback hell)轉向鏈式語法\"></a>特點一:提供好看的 API,由嵌套回調(<a href=\"http://callbackhell.com/\">callback hell</a>)轉向鏈式語法</h4><p>首先將請求用戶信息的 Ajax 異步操作包裝成一個 Promise 實例,後續的同步行為通過該實例對象的<code>then()</code>方法調用。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">getUserInfoPromise</span>(<span class=\"hljs-params\">id</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Promise</span>(<span class=\"hljs-function\">(<span class=\"hljs-params\">fulfill, reject</span>) =></span> {<br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: fulfill,<br> <span class=\"hljs-attr\">error</span>: reject,<br> }) <span class=\"hljs-comment\">// end $.ajax</span><br> }) <span class=\"hljs-comment\">// end return</span><br>} <span class=\"hljs-comment\">// end getUserInfoPromise</span><br><br>getUserInfoPromise(<span class=\"hljs-string\">'10086'</span>)<br> .then(<span class=\"hljs-function\">(<span class=\"hljs-params\">userInfo</span>) =></span> handleUserInfo)<br> .catch(<span class=\"hljs-built_in\">console</span>.log)<br></code></pre></td></tr></table></figure>\n\n<h4 id=\"特點二:Promises-不會與回調綁定耦合,可緩存異步操作結果\"><a href=\"#特點二:Promises-不會與回調綁定耦合,可緩存異步操作結果\" class=\"headerlink\" title=\"特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果\"></a>特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果</h4><p>假設存在這樣的一個業務場景:獲取用戶 id 為<code>10086</code>的用戶信息,然後在不同的兩個階段對其異步操作獲取的用戶信息進行兩種不同的操作(分別為<code>handleUserInfo()</code>和<code>console.log()</code>)。<br>在兩個階段中,都需要異步操作獲取得到的<code>userInfo</code>數據,如果採用傳統回調方式,一般採用閉包的方式緩存<code>userInfo</code>或者暴力點重複進行一次 Ajax 異步請求。</p>\n<p>但是採用 Promise 方式,則無需這些很<strong>複雜</strong>的實現方式,因為可以重複使用 Promise 對象。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">// 閉包緩存</span><br><span class=\"hljs-keyword\">let</span> globUserInfo = <span class=\"hljs-literal\">null</span><br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, <span class=\"hljs-function\">(<span class=\"hljs-params\">userInfo</span>) =></span> (globUserInfo = userInfo))<br><span class=\"hljs-comment\">// 難以保證 globUserInfo 已經更新</span><br>handleUserInfo(globUserInfo)<br><span class=\"hljs-built_in\">console</span>.log(globUserInfo)<br><br><span class=\"hljs-comment\">// 進行了兩次異步操作</span><br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, handleUserInfo)<br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, <span class=\"hljs-built_in\">console</span>.log)<br><br><span class=\"hljs-comment\">// 保存Promise對象</span><br><span class=\"hljs-keyword\">const</span> userInfoPromise = getUserInfoPromise(<span class=\"hljs-string\">'10086'</span>)<br>userInfoPromise.then(handleUserInfo)<br><span class=\"hljs-comment\">// 可以再次使用`userInfoPromise`對象</span><br>userInfoPromise.then(<span class=\"hljs-built_in\">console</span>.log)<br></code></pre></td></tr></table></figure>\n\n<ul>\n<li>這種策略和函數式編程中<a href=\"https://en.wikipedia.org/wiki/Lazy_evaluation\">lazy evaluation</a>概念是類似的,強調<code>call-by-need</code>。</li>\n<li>也可以對異步操作進行柯裡化(<a href=\"https://en.wikipedia.org/wiki/Currying\">Curring</a>)暫存異步操作的結果(類似的概念還有 thunk,參考<a href=\"https://github.com/tj/node-thunkify\">node-thunkify</a>)。</li>\n</ul>\n<h4 id=\"特點三:可組合,復用\"><a href=\"#特點三:可組合,復用\" class=\"headerlink\" title=\"特點三:可組合,復用\"></a>特點三:可組合,復用</h4><p>類似於函數式編程中推廣的從已有的函數中創建新函數,也可以通過已有的 Promise 對象生成新的 Promise 對象。<br>比如獲取多個用戶信息,可使用<code>Promise.all()</code>方法實現異步操作的組合:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> promises = [<span class=\"hljs-string\">'10010'</span>, <span class=\"hljs-string\">'10086'</span>, <span class=\"hljs-string\">'10000'</span>].map(<br> <span class=\"hljs-function\">(<span class=\"hljs-params\">id</span>) =></span><br> <span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Promise</span>(<span class=\"hljs-function\">(<span class=\"hljs-params\">fulfill, reject</span>) =></span><br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: fulfill,<br> <span class=\"hljs-attr\">error</span>: reject,<br> })<br> )<br>)<br><span class=\"hljs-keyword\">const</span> userInfosPromise = <span class=\"hljs-built_in\">Promise</span>.all(promises).then(<span class=\"hljs-built_in\">console</span>.log).catch(<span class=\"hljs-built_in\">console</span>.log)<br></code></pre></td></tr></table></figure>\n\n<blockquote>\n<p>感覺上 Promise/A+規範是函數式編程概念在前端領域的一次最佳實踐(回調的語法糖)。更多詳細的內容待補充。</p>\n</blockquote>\n<h3 id=\"Event-Emitters\"><a href=\"#Event-Emitters\" class=\"headerlink\" title=\"Event Emitters\"></a>Event Emitters</h3><p>事件監聽式異步編程本質上還是依賴於回調函數實現的,區別在於回調函數並不執行異步行為完成後需要的操作,而是發佈一個通知去觸發執行相應的函數。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">import</span> EventEmitter <span class=\"hljs-keyword\">from</span> <span class=\"hljs-string\">'events'</span><br><span class=\"hljs-keyword\">const</span> emitter = <span class=\"hljs-keyword\">new</span> EventEmitter()<br><span class=\"hljs-comment\">// 註冊</span><br>emitter.on(<span class=\"hljs-string\">'event'</span>, handleUserInfo)<br>$.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/10086`</span>,<br> <span class=\"hljs-attr\">success</span>: <span class=\"hljs-function\">(<span class=\"hljs-params\">data</span>) =></span> emitter.emit(<span class=\"hljs-string\">'event'</span>, data), <span class=\"hljs-comment\">// 觸發:異步操作這個行為帶來的影響</span><br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-built_in\">console</span>.log,<br>})<br></code></pre></td></tr></table></figure>\n\n<p>事件監聽其實是觀察者模式的一種實現:當一個對象發生變化時,所有依賴他的相關操作都會得到通知,只不過事件監聽弱化了對象的變化而強調行為(對象數據變更也是一種行為)。<br>比如上面的代碼段強調的是 Ajax 操作這個行為,一旦完成就<strong>通知</strong>到<code>handleUserInfo()</code>函數的調用,並攜帶參數變更對象數據。</p>\n<p>如果採用觀察者模式的話,一般這樣直接處理數據(強調數據變化帶來的影響,造成數據變化的場景可能存在多處),然後觸發數據變動後的行為:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">let</span> userInfo = <span class=\"hljs-literal\">null</span><br>emitter.on(<span class=\"hljs-string\">'event'</span>, <span class=\"hljs-function\">() =></span> handleUserInfo(userInfo))<br><span class=\"hljs-keyword\">const</span> updateUserInfo = <span class=\"hljs-function\"><span class=\"hljs-params\">data</span> =></span> {<br> userInfo = data <span class=\"hljs-comment\">// userInfo對象方式變更</span><br> emitter.emit(<span class=\"hljs-string\">'event'</span>) <span class=\"hljs-comment\">// 通知相關依賴的操作:數據變更帶來的影響</span><br>}<br>$.ajax(<br> url: <span class=\"hljs-string\">`**/api/v1.0/user/10086`</span>,<br> <span class=\"hljs-attr\">success</span>: updateUserInfo, <span class=\"hljs-comment\">// 觸發</span><br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-built_in\">console</span>.log,<br>})<br></code></pre></td></tr></table></figure>\n\n<p>很明顯,觀察者模式要比事件監聽方式擴充性更強(雖然本質一致,但是強調側重點不同)。</p>\n<figure style=\"padding-top: 0;margin-top: .5em;\">\n <img src=\"//solome.js.org/static/javascript-async/pingpong.gif\" />\n <figcaption>陷入`emit`死循環</figcaption>\n</figure>\n\n<p>事件監聽式異步編程無異於<code>goto</code>語句,稍有不慎形如<code>on()</code>、<code>emit()</code>、<code>subscribe()</code>、<code>publish()</code>等方法摻雜在各處,“剪不清,理還亂”;如果不是“約定”化編程不建議採用。比如下面這段源碼,稍不慎就陷入如圖 1 所示場景。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> emitter = <span class=\"hljs-keyword\">new</span> EventEmitter()<br><span class=\"hljs-keyword\">const</span> foo = <span class=\"hljs-function\">() =></span> emitter.emit(<span class=\"hljs-string\">'bar'</span>)<br><span class=\"hljs-keyword\">const</span> bar = <span class=\"hljs-function\">() =></span> emitter.emit(<span class=\"hljs-string\">'foo'</span>)<br>emitter.on(<span class=\"hljs-string\">'foo'</span>, foo)<br>emitter.on(<span class=\"hljs-string\">'bar'</span>, bar)<br>foo() <span class=\"hljs-comment\">// 陷入死循環</span><br></code></pre></td></tr></table></figure>\n\n<p>和回調式異步編程(包括 Promises/A+規範)相比,事件監聽式異步編程的軟肋在於需要手動註冊(Manual)。<br>原本可以通過數據綁定(Data binding)<a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe\">Object.observe()</a>方法來實現觀察者模式,很可惜該方法已被<code>deprecated</code>掉;目前推薦的是<code>get</code>和<code>set</code>+<a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy\">Proxy</a>方式實現(相關討論:<a href=\"http://stackoverflow.com/questions/36258502/why-object-observe-has-been-deprecated\">36258502</a>)。</p>\n<p>但是手動維護這些<code>on()</code>、<code>emit()</code>、<code>get()</code>、<code>set()</code>等方法在項目是很折騰的,通過一些第三方工具包可以實現由<code>Manual</code>到<code>Automatic</code>轉變。<br>比如採用<a href=\"http://mobxjs.github.io/mobx/index.html\">MobX</a>可以實現得更加優雅:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">import</span> { observable, autorun } <span class=\"hljs-keyword\">from</span> <span class=\"hljs-string\">'mobx'</span><br><span class=\"hljs-keyword\">const</span> store = observable({<span class=\"hljs-attr\">userInfo</span>: <span class=\"hljs-literal\">null</span>})<br><span class=\"hljs-comment\">// 只要變動`store`對象,就會自動觸發`handleUserInfo()`函數</span><br>autorun(<span class=\"hljs-function\">() =></span> handleUserInfo(store.userInfo))<br>$.ajax(<br> url: <span class=\"hljs-string\">`**/api/v1.0/user/10086`</span>,<br> <span class=\"hljs-attr\">success</span>: <span class=\"hljs-function\"><span class=\"hljs-params\">data</span> =></span> store.userInfo = data,<br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-built_in\">console</span>.log,<br>})<br></code></pre></td></tr></table></figure>\n\n<h3 id=\"Generator\"><a href=\"#Generator\" class=\"headerlink\" title=\"Generator\"></a>Generator</h3><h4 id=\"Coroutine-協程-a-k-a-co-operative-routines\"><a href=\"#Coroutine-協程-a-k-a-co-operative-routines\" class=\"headerlink\" title=\"Coroutine 協程 (a.k.a. co-operative routines)\"></a><a href=\"https://en.wikipedia.org/wiki/Coroutine\">Coroutine</a> 協程 (a.k.a. co-operative routines)</h4><p>一般程序中,函數調用一定是從頭到尾執行直到遇到<code>return</code>或執行完;<br>而 coroutine 則容許函數執行到一半時就中斷(yield),中斷時函數內部上下文環境(context)會被緩存下來。<br>程序主體可以隨時恢復(resume)這個被緩存的 coroutine,繼續從剛才被中斷處執行後續內容。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span>* <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\"></span>) </span>{<br> <span class=\"hljs-built_in\">console</span>.log(<span class=\"hljs-string\">'hello'</span>)<br> <span class=\"hljs-keyword\">yield</span> <span class=\"hljs-number\">10086</span> <span class=\"hljs-comment\">// 在此處中斷 coroutine</span><br> <span class=\"hljs-built_in\">console</span>.log(<span class=\"hljs-string\">'world'</span>)<br>}<br><br><span class=\"hljs-keyword\">const</span> bar = foo() <span class=\"hljs-comment\">// 保存 coroutine 內部狀態的變量</span><br>bar.next() <span class=\"hljs-comment\">// 調用`foo()`函數,遇到 yield 中斷程序調用</span><br><span class=\"hljs-built_in\">console</span>.log(<span class=\"hljs-string\">'main, not in `foo()`'</span>) <span class=\"hljs-comment\">// 已經從`foo()`函數中跳出來了,可以幹些其它事情</span><br>bar.next() <span class=\"hljs-comment\">// 恢復`foo()`的調用,從 yield 中斷處繼續執行</span><br></code></pre></td></tr></table></figure>\n\n<h4 id=\"Thread-VS-Coroutine\"><a href=\"#Thread-VS-Coroutine\" class=\"headerlink\" title=\"Thread VS Coroutine\"></a>Thread VS Coroutine</h4><blockquote>\n<p>With threads, the operating system switches running threads preemptively according to its scheduler,<br>which is an algorithm in the operating system kernel.<br>With coroutines, the programmer and programming language determine when to switch coroutines;<br>in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points,<br>typically (but not necessarily) within a single thread.<br /><br>—— <a href=\"http://stackoverflow.com/questions/1934715/difference-between-a-coroutine-and-a-thread\">stackoverflow: difference-between-a-coroutine-and-a-thread</a></p>\n</blockquote>\n<h4 id=\"Generator-a-k-a-semicoroutines-VS-Coroutine\"><a href=\"#Generator-a-k-a-semicoroutines-VS-Coroutine\" class=\"headerlink\" title=\"Generator (a.k.a. semicoroutines) VS Coroutine\"></a><a href=\"https://en.wikipedia.org/wiki/Generator_(computer_programming)\">Generator</a> (a.k.a. semicoroutines) VS Coroutine</h4><p>Generator 與 Coroutine 的區別是 Generator 只能從上次中斷處繼續執行,而 Coroutine 則沒有這樣的限制(可以指定從哪裡繼續執行)。<br>因此,Generator 可以視作是 Coroutine 的一種特殊情況,上文涉及的源碼例子其實就是 Generator 的應用舉例。<br>其中,Generator 涉及<code>bar.next()</code>自動流程管理的解決方案可以參考<a href=\"https://github.com/tj/co\">co</a>、<a href=\"https://github.com/thunks/thunks\">thunks</a>等。</p>\n<h4 id=\"async-await-“語法糖”\"><a href=\"#async-await-“語法糖”\" class=\"headerlink\" title=\"async/await “語法糖”\"></a>async/await “語法糖”</h4><p>聲明的<code>async</code>函數就是將 Generator 函數和自動執行器包裝在一個函數裡面(參考<a href=\"https://gist.github.com/solome/064e48f5205943dff7d4918b3bf18e0d\">async2generator()</a>),<br>以達到異步編碼編程模式與同步編碼一致。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> run = <span class=\"hljs-keyword\">async</span> () => {<br> <span class=\"hljs-keyword\">const</span> userInfo = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Promise</span>(<span class=\"hljs-function\">(<span class=\"hljs-params\">fulfill, reject</span>) =></span> {<br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: fulfill,<br> <span class=\"hljs-attr\">error</span>: reject,<br> }) <span class=\"hljs-comment\">// end $.ajax</span><br> }) <span class=\"hljs-comment\">// end return</span><br> handleUserInfo(userInfo)<br>}<br></code></pre></td></tr></table></figure>\n\n<h3 id=\"不是總結的總結\"><a href=\"#不是總結的總結\" class=\"headerlink\" title=\"不是總結的總結\"></a>不是總結的總結</h3><ul>\n<li>函數式編程領域的知識還是要多多接觸的。</li>\n<li>有些前端領域的新鮮事物在其他領域可能就是些習以為常的東西,擴充知識面很重要。</li>\n</ul>\n<h3 id=\"References\"><a href=\"#References\" class=\"headerlink\" title=\"References\"></a>References</h3><ul>\n<li><a href=\"http://callbackhell.com/\">Callback Hell</a>: A guide to writing asynchronous JavaScript programs.</li>\n<li><a href=\"https://promisesaplus.com/\">Promises/A+</a>: An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.</li>\n<li><a href=\"https://www.promisejs.org/\">promisejs.org</a>: A website dedicated to promises in JavaScript.</li>\n<li><a href=\"https://facebook.github.io/regenerator/\">regenerator</a>: Source transformer enabling ECMAScript 6 generator functions in JavaScript-of-today.</li>\n<li><a href=\"https://github.com/tc39/ecmascript-asyncawait\">ecmascript-asyncawait</a>: Async/await for ECMAScript.</li>\n<li><a href=\"http://devsmash.com/blog/whats-the-big-deal-with-generators\">whats-the-big-deal-with-generators</a></li>\n<li><a href=\"http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4287.pdf\">Threads, Fibers & Coroutines</a></li>\n</ul>\n","site":{"data":{}},"excerpt":"","more":"<p>JavaScript 是單線程的,除了你的 JS 代码,其它操作都是并行执行的(everything runs in parallel except your code)。</p>\n<p>在 JS 執行線程中進行的行為被稱作同步(Synchronous)操作,非 JS 執行線程執行的行為則被稱呼為異步(Asynchronous)操作。<br>諸如 Ajax/HTTP 請求、I/O 操作等行為均與 JS 執行線程無關(由自己獨立的線程進行運作),這些行為在執行完成之後會將結果通知到 JS 執行線程;<br>因此,JS 執行線程中會有個類似<code>while(true)</code>的循環,以觀察者的姿態<code>監聽</code>(轮询)是否有其它線程傳遞消息過來,一旦捕獲到則執行本 JS 執行線程中相應的函數塊(回調)。</p>\n<p>JavaScript 事件循環不是本文的重點(<a href=\"https://docs.google.com/presentation/d/1-UC3cwd0KZtdSRAd6edLD-CvrOeM-IOpJYcb8rhElBY/edit?usp=sharing\">JavaScript Event Loop</a>),本文僅對前端異步編程進行些許總結。個人的理解是 JavaScript 異步編程方式只有兩種方式:回調和觀察者模式。需要注意的是:</p>\n<ul>\n<li>Promises/A+ 是如何優雅地使用回調而設計的一種編程規範,本質依舊是回調</li>\n<li>事件監聽和觀察者模式(發佈/訂閱模式)完全可以理解成是“一個孩子的不同暱稱”</li>\n<li>Generators 是一種特性,實現函數在執行過程中暫停、並在將來的某個時刻恢復執行的功能</li>\n<li>Generators+Promises 可以搭配漂亮的語法糖,將異步源碼寫得像同步源碼</li>\n</ul>\n<h3 id=\"Callback-Functions\"><a href=\"#Callback-Functions\" class=\"headerlink\" title=\"Callback Functions\"></a>Callback Functions</h3><p>函數式編程中有個概念叫做高階函數(Higher-order Functions),其有個特性是一個函數可以作為另外一個函數的參數。通常我們將那個作為另外一個函數參數的函數稱呼為回調函數。</p>\n<p>為方便描述和解釋,此處模擬一個具體的業務場景:通過 Ajax 方式請求<code>**/api/v1.0/user/{id}</code>接口獲取某個用戶的信息(Asynchronous behavior),然後針對拿到的用戶信息進行後續的處理。<br>典型的做法是將<code>Ajax</code>異步請求之後進行的操作封裝成<code>callback()</code>函數,在接口訪問成功得到用戶信息之後再執行該函數:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">getUserInfoCallback</span>(<span class=\"hljs-params\">id, callback</span>) </span>{<br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: <span class=\"hljs-function\"><span class=\"hljs-params\">data</span> =></span> callback(<span class=\"hljs-literal\">null</span>, data),<br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-function\">(<span class=\"hljs-params\">xhr, textStatus, errorThrown</span>)</span><br><span class=\"hljs-function\"> =></span> callback(<span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Error</span>(textStatus), errorThrown),<br> })<br>}<br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, handleUserInfo)<br></code></pre></td></tr></table></figure>\n\n<h3 id=\"Promises-A\"><a href=\"#Promises-A\" class=\"headerlink\" title=\"Promises/A+\"></a>Promises/A+</h3><blockquote>\n<p>An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.</p>\n</blockquote>\n<p>濫用回調帶來的問題是代碼邏輯耦合度很高,面臨回調災難。Promises/A+是種合理使用回調的<strong>規範</strong>,避免回調的濫用。</p>\n<h4 id=\"特點一:提供好看的-API,由嵌套回調-callback-hell-轉向鏈式語法\"><a href=\"#特點一:提供好看的-API,由嵌套回調-callback-hell-轉向鏈式語法\" class=\"headerlink\" title=\"特點一:提供好看的 API,由嵌套回調(callback hell)轉向鏈式語法\"></a>特點一:提供好看的 API,由嵌套回調(<a href=\"http://callbackhell.com/\">callback hell</a>)轉向鏈式語法</h4><p>首先將請求用戶信息的 Ajax 異步操作包裝成一個 Promise 實例,後續的同步行為通過該實例對象的<code>then()</code>方法調用。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">getUserInfoPromise</span>(<span class=\"hljs-params\">id</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Promise</span>(<span class=\"hljs-function\">(<span class=\"hljs-params\">fulfill, reject</span>) =></span> {<br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: fulfill,<br> <span class=\"hljs-attr\">error</span>: reject,<br> }) <span class=\"hljs-comment\">// end $.ajax</span><br> }) <span class=\"hljs-comment\">// end return</span><br>} <span class=\"hljs-comment\">// end getUserInfoPromise</span><br><br>getUserInfoPromise(<span class=\"hljs-string\">'10086'</span>)<br> .then(<span class=\"hljs-function\">(<span class=\"hljs-params\">userInfo</span>) =></span> handleUserInfo)<br> .catch(<span class=\"hljs-built_in\">console</span>.log)<br></code></pre></td></tr></table></figure>\n\n<h4 id=\"特點二:Promises-不會與回調綁定耦合,可緩存異步操作結果\"><a href=\"#特點二:Promises-不會與回調綁定耦合,可緩存異步操作結果\" class=\"headerlink\" title=\"特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果\"></a>特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果</h4><p>假設存在這樣的一個業務場景:獲取用戶 id 為<code>10086</code>的用戶信息,然後在不同的兩個階段對其異步操作獲取的用戶信息進行兩種不同的操作(分別為<code>handleUserInfo()</code>和<code>console.log()</code>)。<br>在兩個階段中,都需要異步操作獲取得到的<code>userInfo</code>數據,如果採用傳統回調方式,一般採用閉包的方式緩存<code>userInfo</code>或者暴力點重複進行一次 Ajax 異步請求。</p>\n<p>但是採用 Promise 方式,則無需這些很<strong>複雜</strong>的實現方式,因為可以重複使用 Promise 對象。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">// 閉包緩存</span><br><span class=\"hljs-keyword\">let</span> globUserInfo = <span class=\"hljs-literal\">null</span><br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, <span class=\"hljs-function\">(<span class=\"hljs-params\">userInfo</span>) =></span> (globUserInfo = userInfo))<br><span class=\"hljs-comment\">// 難以保證 globUserInfo 已經更新</span><br>handleUserInfo(globUserInfo)<br><span class=\"hljs-built_in\">console</span>.log(globUserInfo)<br><br><span class=\"hljs-comment\">// 進行了兩次異步操作</span><br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, handleUserInfo)<br>getUserInfoCallback(<span class=\"hljs-string\">'10086'</span>, <span class=\"hljs-built_in\">console</span>.log)<br><br><span class=\"hljs-comment\">// 保存Promise對象</span><br><span class=\"hljs-keyword\">const</span> userInfoPromise = getUserInfoPromise(<span class=\"hljs-string\">'10086'</span>)<br>userInfoPromise.then(handleUserInfo)<br><span class=\"hljs-comment\">// 可以再次使用`userInfoPromise`對象</span><br>userInfoPromise.then(<span class=\"hljs-built_in\">console</span>.log)<br></code></pre></td></tr></table></figure>\n\n<ul>\n<li>這種策略和函數式編程中<a href=\"https://en.wikipedia.org/wiki/Lazy_evaluation\">lazy evaluation</a>概念是類似的,強調<code>call-by-need</code>。</li>\n<li>也可以對異步操作進行柯裡化(<a href=\"https://en.wikipedia.org/wiki/Currying\">Curring</a>)暫存異步操作的結果(類似的概念還有 thunk,參考<a href=\"https://github.com/tj/node-thunkify\">node-thunkify</a>)。</li>\n</ul>\n<h4 id=\"特點三:可組合,復用\"><a href=\"#特點三:可組合,復用\" class=\"headerlink\" title=\"特點三:可組合,復用\"></a>特點三:可組合,復用</h4><p>類似於函數式編程中推廣的從已有的函數中創建新函數,也可以通過已有的 Promise 對象生成新的 Promise 對象。<br>比如獲取多個用戶信息,可使用<code>Promise.all()</code>方法實現異步操作的組合:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> promises = [<span class=\"hljs-string\">'10010'</span>, <span class=\"hljs-string\">'10086'</span>, <span class=\"hljs-string\">'10000'</span>].map(<br> <span class=\"hljs-function\">(<span class=\"hljs-params\">id</span>) =></span><br> <span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Promise</span>(<span class=\"hljs-function\">(<span class=\"hljs-params\">fulfill, reject</span>) =></span><br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: fulfill,<br> <span class=\"hljs-attr\">error</span>: reject,<br> })<br> )<br>)<br><span class=\"hljs-keyword\">const</span> userInfosPromise = <span class=\"hljs-built_in\">Promise</span>.all(promises).then(<span class=\"hljs-built_in\">console</span>.log).catch(<span class=\"hljs-built_in\">console</span>.log)<br></code></pre></td></tr></table></figure>\n\n<blockquote>\n<p>感覺上 Promise/A+規範是函數式編程概念在前端領域的一次最佳實踐(回調的語法糖)。更多詳細的內容待補充。</p>\n</blockquote>\n<h3 id=\"Event-Emitters\"><a href=\"#Event-Emitters\" class=\"headerlink\" title=\"Event Emitters\"></a>Event Emitters</h3><p>事件監聽式異步編程本質上還是依賴於回調函數實現的,區別在於回調函數並不執行異步行為完成後需要的操作,而是發佈一個通知去觸發執行相應的函數。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">import</span> EventEmitter <span class=\"hljs-keyword\">from</span> <span class=\"hljs-string\">'events'</span><br><span class=\"hljs-keyword\">const</span> emitter = <span class=\"hljs-keyword\">new</span> EventEmitter()<br><span class=\"hljs-comment\">// 註冊</span><br>emitter.on(<span class=\"hljs-string\">'event'</span>, handleUserInfo)<br>$.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/10086`</span>,<br> <span class=\"hljs-attr\">success</span>: <span class=\"hljs-function\">(<span class=\"hljs-params\">data</span>) =></span> emitter.emit(<span class=\"hljs-string\">'event'</span>, data), <span class=\"hljs-comment\">// 觸發:異步操作這個行為帶來的影響</span><br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-built_in\">console</span>.log,<br>})<br></code></pre></td></tr></table></figure>\n\n<p>事件監聽其實是觀察者模式的一種實現:當一個對象發生變化時,所有依賴他的相關操作都會得到通知,只不過事件監聽弱化了對象的變化而強調行為(對象數據變更也是一種行為)。<br>比如上面的代碼段強調的是 Ajax 操作這個行為,一旦完成就<strong>通知</strong>到<code>handleUserInfo()</code>函數的調用,並攜帶參數變更對象數據。</p>\n<p>如果採用觀察者模式的話,一般這樣直接處理數據(強調數據變化帶來的影響,造成數據變化的場景可能存在多處),然後觸發數據變動後的行為:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">let</span> userInfo = <span class=\"hljs-literal\">null</span><br>emitter.on(<span class=\"hljs-string\">'event'</span>, <span class=\"hljs-function\">() =></span> handleUserInfo(userInfo))<br><span class=\"hljs-keyword\">const</span> updateUserInfo = <span class=\"hljs-function\"><span class=\"hljs-params\">data</span> =></span> {<br> userInfo = data <span class=\"hljs-comment\">// userInfo對象方式變更</span><br> emitter.emit(<span class=\"hljs-string\">'event'</span>) <span class=\"hljs-comment\">// 通知相關依賴的操作:數據變更帶來的影響</span><br>}<br>$.ajax(<br> url: <span class=\"hljs-string\">`**/api/v1.0/user/10086`</span>,<br> <span class=\"hljs-attr\">success</span>: updateUserInfo, <span class=\"hljs-comment\">// 觸發</span><br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-built_in\">console</span>.log,<br>})<br></code></pre></td></tr></table></figure>\n\n<p>很明顯,觀察者模式要比事件監聽方式擴充性更強(雖然本質一致,但是強調側重點不同)。</p>\n<figure style=\"padding-top: 0;margin-top: .5em;\">\n <img src=\"//solome.js.org/static/javascript-async/pingpong.gif\" />\n <figcaption>陷入`emit`死循環</figcaption>\n</figure>\n\n<p>事件監聽式異步編程無異於<code>goto</code>語句,稍有不慎形如<code>on()</code>、<code>emit()</code>、<code>subscribe()</code>、<code>publish()</code>等方法摻雜在各處,“剪不清,理還亂”;如果不是“約定”化編程不建議採用。比如下面這段源碼,稍不慎就陷入如圖 1 所示場景。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> emitter = <span class=\"hljs-keyword\">new</span> EventEmitter()<br><span class=\"hljs-keyword\">const</span> foo = <span class=\"hljs-function\">() =></span> emitter.emit(<span class=\"hljs-string\">'bar'</span>)<br><span class=\"hljs-keyword\">const</span> bar = <span class=\"hljs-function\">() =></span> emitter.emit(<span class=\"hljs-string\">'foo'</span>)<br>emitter.on(<span class=\"hljs-string\">'foo'</span>, foo)<br>emitter.on(<span class=\"hljs-string\">'bar'</span>, bar)<br>foo() <span class=\"hljs-comment\">// 陷入死循環</span><br></code></pre></td></tr></table></figure>\n\n<p>和回調式異步編程(包括 Promises/A+規範)相比,事件監聽式異步編程的軟肋在於需要手動註冊(Manual)。<br>原本可以通過數據綁定(Data binding)<a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/observe\">Object.observe()</a>方法來實現觀察者模式,很可惜該方法已被<code>deprecated</code>掉;目前推薦的是<code>get</code>和<code>set</code>+<a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy\">Proxy</a>方式實現(相關討論:<a href=\"http://stackoverflow.com/questions/36258502/why-object-observe-has-been-deprecated\">36258502</a>)。</p>\n<p>但是手動維護這些<code>on()</code>、<code>emit()</code>、<code>get()</code>、<code>set()</code>等方法在項目是很折騰的,通過一些第三方工具包可以實現由<code>Manual</code>到<code>Automatic</code>轉變。<br>比如採用<a href=\"http://mobxjs.github.io/mobx/index.html\">MobX</a>可以實現得更加優雅:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">import</span> { observable, autorun } <span class=\"hljs-keyword\">from</span> <span class=\"hljs-string\">'mobx'</span><br><span class=\"hljs-keyword\">const</span> store = observable({<span class=\"hljs-attr\">userInfo</span>: <span class=\"hljs-literal\">null</span>})<br><span class=\"hljs-comment\">// 只要變動`store`對象,就會自動觸發`handleUserInfo()`函數</span><br>autorun(<span class=\"hljs-function\">() =></span> handleUserInfo(store.userInfo))<br>$.ajax(<br> url: <span class=\"hljs-string\">`**/api/v1.0/user/10086`</span>,<br> <span class=\"hljs-attr\">success</span>: <span class=\"hljs-function\"><span class=\"hljs-params\">data</span> =></span> store.userInfo = data,<br> <span class=\"hljs-attr\">error</span>: <span class=\"hljs-built_in\">console</span>.log,<br>})<br></code></pre></td></tr></table></figure>\n\n<h3 id=\"Generator\"><a href=\"#Generator\" class=\"headerlink\" title=\"Generator\"></a>Generator</h3><h4 id=\"Coroutine-協程-a-k-a-co-operative-routines\"><a href=\"#Coroutine-協程-a-k-a-co-operative-routines\" class=\"headerlink\" title=\"Coroutine 協程 (a.k.a. co-operative routines)\"></a><a href=\"https://en.wikipedia.org/wiki/Coroutine\">Coroutine</a> 協程 (a.k.a. co-operative routines)</h4><p>一般程序中,函數調用一定是從頭到尾執行直到遇到<code>return</code>或執行完;<br>而 coroutine 則容許函數執行到一半時就中斷(yield),中斷時函數內部上下文環境(context)會被緩存下來。<br>程序主體可以隨時恢復(resume)這個被緩存的 coroutine,繼續從剛才被中斷處執行後續內容。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span>* <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\"></span>) </span>{<br> <span class=\"hljs-built_in\">console</span>.log(<span class=\"hljs-string\">'hello'</span>)<br> <span class=\"hljs-keyword\">yield</span> <span class=\"hljs-number\">10086</span> <span class=\"hljs-comment\">// 在此處中斷 coroutine</span><br> <span class=\"hljs-built_in\">console</span>.log(<span class=\"hljs-string\">'world'</span>)<br>}<br><br><span class=\"hljs-keyword\">const</span> bar = foo() <span class=\"hljs-comment\">// 保存 coroutine 內部狀態的變量</span><br>bar.next() <span class=\"hljs-comment\">// 調用`foo()`函數,遇到 yield 中斷程序調用</span><br><span class=\"hljs-built_in\">console</span>.log(<span class=\"hljs-string\">'main, not in `foo()`'</span>) <span class=\"hljs-comment\">// 已經從`foo()`函數中跳出來了,可以幹些其它事情</span><br>bar.next() <span class=\"hljs-comment\">// 恢復`foo()`的調用,從 yield 中斷處繼續執行</span><br></code></pre></td></tr></table></figure>\n\n<h4 id=\"Thread-VS-Coroutine\"><a href=\"#Thread-VS-Coroutine\" class=\"headerlink\" title=\"Thread VS Coroutine\"></a>Thread VS Coroutine</h4><blockquote>\n<p>With threads, the operating system switches running threads preemptively according to its scheduler,<br>which is an algorithm in the operating system kernel.<br>With coroutines, the programmer and programming language determine when to switch coroutines;<br>in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points,<br>typically (but not necessarily) within a single thread.<br /><br>—— <a href=\"http://stackoverflow.com/questions/1934715/difference-between-a-coroutine-and-a-thread\">stackoverflow: difference-between-a-coroutine-and-a-thread</a></p>\n</blockquote>\n<h4 id=\"Generator-a-k-a-semicoroutines-VS-Coroutine\"><a href=\"#Generator-a-k-a-semicoroutines-VS-Coroutine\" class=\"headerlink\" title=\"Generator (a.k.a. semicoroutines) VS Coroutine\"></a><a href=\"https://en.wikipedia.org/wiki/Generator_(computer_programming)\">Generator</a> (a.k.a. semicoroutines) VS Coroutine</h4><p>Generator 與 Coroutine 的區別是 Generator 只能從上次中斷處繼續執行,而 Coroutine 則沒有這樣的限制(可以指定從哪裡繼續執行)。<br>因此,Generator 可以視作是 Coroutine 的一種特殊情況,上文涉及的源碼例子其實就是 Generator 的應用舉例。<br>其中,Generator 涉及<code>bar.next()</code>自動流程管理的解決方案可以參考<a href=\"https://github.com/tj/co\">co</a>、<a href=\"https://github.com/thunks/thunks\">thunks</a>等。</p>\n<h4 id=\"async-await-“語法糖”\"><a href=\"#async-await-“語法糖”\" class=\"headerlink\" title=\"async/await “語法糖”\"></a>async/await “語法糖”</h4><p>聲明的<code>async</code>函數就是將 Generator 函數和自動執行器包裝在一個函數裡面(參考<a href=\"https://gist.github.com/solome/064e48f5205943dff7d4918b3bf18e0d\">async2generator()</a>),<br>以達到異步編碼編程模式與同步編碼一致。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> run = <span class=\"hljs-keyword\">async</span> () => {<br> <span class=\"hljs-keyword\">const</span> userInfo = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-keyword\">new</span> <span class=\"hljs-built_in\">Promise</span>(<span class=\"hljs-function\">(<span class=\"hljs-params\">fulfill, reject</span>) =></span> {<br> $.ajax({<br> <span class=\"hljs-attr\">url</span>: <span class=\"hljs-string\">`**/api/v1.0/user/<span class=\"hljs-subst\">${id}</span>`</span>,<br> <span class=\"hljs-attr\">success</span>: fulfill,<br> <span class=\"hljs-attr\">error</span>: reject,<br> }) <span class=\"hljs-comment\">// end $.ajax</span><br> }) <span class=\"hljs-comment\">// end return</span><br> handleUserInfo(userInfo)<br>}<br></code></pre></td></tr></table></figure>\n\n<h3 id=\"不是總結的總結\"><a href=\"#不是總結的總結\" class=\"headerlink\" title=\"不是總結的總結\"></a>不是總結的總結</h3><ul>\n<li>函數式編程領域的知識還是要多多接觸的。</li>\n<li>有些前端領域的新鮮事物在其他領域可能就是些習以為常的東西,擴充知識面很重要。</li>\n</ul>\n<h3 id=\"References\"><a href=\"#References\" class=\"headerlink\" title=\"References\"></a>References</h3><ul>\n<li><a href=\"http://callbackhell.com/\">Callback Hell</a>: A guide to writing asynchronous JavaScript programs.</li>\n<li><a href=\"https://promisesaplus.com/\">Promises/A+</a>: An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.</li>\n<li><a href=\"https://www.promisejs.org/\">promisejs.org</a>: A website dedicated to promises in JavaScript.</li>\n<li><a href=\"https://facebook.github.io/regenerator/\">regenerator</a>: Source transformer enabling ECMAScript 6 generator functions in JavaScript-of-today.</li>\n<li><a href=\"https://github.com/tc39/ecmascript-asyncawait\">ecmascript-asyncawait</a>: Async/await for ECMAScript.</li>\n<li><a href=\"http://devsmash.com/blog/whats-the-big-deal-with-generators\">whats-the-big-deal-with-generators</a></li>\n<li><a href=\"http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4287.pdf\">Threads, Fibers & Coroutines</a></li>\n</ul>\n"},{"layout":"layout/post","title":"前端内存分析之图片篇","date":"2022-03-11T15:30:00.000Z","comments":1,"_content":"\n## 背景\n\n二零年年末,我所在如视的前端团队针对核心 C 端项目 **VR 3D 看房** 做了次从 2.0 到 3.0 的系统重构——交互风格、前端架构等等都重新整了遍。灰度阶段前,通过[ PerfDog 性能狗](https://perfdog.qq.com/) 性能分析发现:我们一个 VR 3D 页面在 PC 端占用 120MB 左右内存,在 iPhone 12 上竟然高达 360MB。\n\n在加上业务能力的升级——除了传统实景 VR 之外,我们还新增了虚拟 VR 用以展示房源装修前后的效果对比,这又新增了一个 VR 实例,内存占用已超 700MB。\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/memory-usage-pic/memo-vr.png\" /></div>\n </div>\n <figcaption>图一:2.0和3.0 内存占用情况</figcaption>\n</figure>\n\n此外,随着用户的交互(开启地图、逐帧动画等),内存还在不断递增,高峰期已经超过 1G。而 iOS 系统 WebView 内存溢出的阈值最高也才 1.5G,VR 页面已经濒临崩溃。\n\n很好奇为啥会占用了那么多的内存?让我们来简单探究一下吧。\n\n## 图片内存占用\n\n三维模型一般由面片数据(顶点、线)和贴图组成,内存占用的大头是图片。那一张图片渲染至浏览器占用的内存该怎么计算呢?\n\n一般浏览器渲染图片**BitMap**选用的是 [ARGB_8888](https://en.wikipedia.org/wiki/RGBA_color_model#RGBA8888):颜色信息由透明度 A(Alpha)与 R(Red),G(Green),B(Blue)四部分组成,每个部分都占 8 位,总共占 32 位。即一个像素:\n\n> - **A** - alpha 透明 8bit(位)\n> - **R** - Red 8bit(位)\n> - **G** - Green 8bit(位)\n> - **B** - Blue 8bit(位)\n\n\"1Byte(字节)=8bit(位)\" 因此,**一个像素会占用四个字节**。\n所以一张 `2048*2048` 的图片占用的内存有:`2048*2048*4 Byte`,换算成 MB 单位 `2048*2048*4/ (1024*1024) Byte = 16MB`。\n\n> 图片占用的内存跟图片文件体积大小无关,仅跟其分辨率相关。压缩图片目的是为了 CDN 下载速度更快、节省存储空间,但无法节省浏览器占用内存。\n\n**\"一个像素会占用四个字节\"** 这个结论适用于绝大部分 PC、macOS 等终端设备,但在移动端并不完全适用,详细内容请往下看。\n\n## 终端设备\n\n以 iPhone 为例,先统计下历代 iPhone 屏幕信息:\n\n| 机型 | 逻辑像素 | 渲染像素 | 物理像素 | 设备像素比 DPR | 一个像素用几个字节 |\n| :------------------------: | :------: | :--------: | :--------: | :------------: | :-----------------: |\n| iPhone 3G/3Gs | 320\\*480 | 320\\*480 | 320\\*480 | 1 | 4 个字节 |\n| iPhone 4/4s | 320\\*480 | 640\\*960 | 640\\*960 | 2 | 4 \\* 2 个字节 |\n| iPhone 5/5C/5s/SE | 320\\*568 | 640\\*1136 | 640\\*1136 | 2 | 4 \\* 2 个字节 |\n| iPhone 6/6s/7/8/SE2 | 375\\*667 | 750\\*1334 | 750\\*1334 | 2 | 4 \\* 2 个字节 |\n| iPhone XR/11 | 414\\*896 | 828\\*1792 | 828\\*1792 | 2 | 4 \\* 2 个字节 |\n| iPhone X/Xs/11 Pro | 375\\*812 | 1125\\*2436 | 1125\\*2436 | 3 | 4 \\* 3 个字节 |\n| iPhone 12 mini | 375\\*812 | 1125\\*2436 | 1080\\*2340 | 3 | 约 4 \\* 2.88 个字节 |\n| iPhone 12/12 Pro | 390\\*844 | 1170\\*2532 | 1170\\*2532 | 3 | 4 \\* 3 个字节 |\n| iPhone 6/6s/7/8/ Plus | 414\\*736 | 1242\\*2208 | 1080\\*1920 | 3 | 约 4 \\* 2.61 个字节 |\n| iPhone Xs Max / 11 Pro Max | 414\\*896 | 1242\\*2688 | 1242\\*2688 | 3 | 4 \\* 3 个字节 |\n| iPhone 12 Pro Max | 428\\*926 | 1284\\*2778 | 1284\\*2778 | 3 | 4 \\* 3 个字节 |\n\n> - **物理像素**:硬件真实的像素,即屏幕分辨率。\n> - **逻辑像素**:前端使用的像素,即 `px`。\n> - **渲染像素**:操作系统抽象的像素。\n\n从 iPhone 4 代开始,iPhone 屏幕的物理分辨率是很高的,除了 \"iPhone 6/6s/7/8/ Plus\" 和 \"iPhone 12 mini\" 设备之外,iOS 系统基本是把 2 个或 3 个物理像素当作 1 个逻辑像素来使用的(放大倍数了)。\n\nAndroid 系统则比较凌乱,但本质还是**将多个物理像素当作一个逻辑像素来渲染使用**。因此,一张`2048*2048`图片内存占用换算公式是: `(物理分辨率/逻辑像素)*2048*2048*4/ (1024*1024) MB`。\n\n这基本解释了移动端设备图片占用的内存要比 PC 上统计的要多出 2 倍、3 倍甚至 4 倍以上。这也解释了明明是旗舰机型崩溃率反而增加了,比如 iOS 系统 WebView 内存崩溃的阈值固定在 1.5G 以下,旗舰机型 iPhone 12 Pro Max 更加容易达到这个阈值。\n\n> 介于设备屏幕 LCD、OLED 等材质差异,实际统计会有些许偏差,但是数量级不会有太多出入。\n\n## Five 实例内存占用\n\n[`@realsee/five`](https://realsee.js.org/docs/front/3d-space/get-started/rendering-engine) 是如视基于 Three.js 实现的在浏览器环境中运行的**三维空间渲染引擎**。创建 `Five` 实例并渲染一个三维空间需要耗费多少内存呢?\n\n常态情况下,`Five` 渲染依赖的图片是三维模型的 UV 贴图和一个立方体全景贴图(立方体六个面六张图),如图二、三所示。\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:60%;\" src=\"//solome.js.org/static/memory-usage-pic/pano.png\" /></div>\n </div>\n <figcaption>图二:立方体全景贴图(2048*2048)</figcaption>\n</figure>\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:80%;\" src=\"//solome.js.org/static/memory-usage-pic/model.png\" /></div>\n </div>\n <figcaption>图三:UV 贴图及网格数据组成模型(512*512)</figcaption>\n</figure>\n\n因此,我们以[贝壳·VR 看房 | 常楹公元 2 室 1 厅](https://open.realsee.com/ke/6gyq3v1verxD7JO1/qeNadDJvp5oSPhzhbTo7mVEC3LM4rOA2/?v3=1) 房源为例,其实景 VR 的 UV 贴图有 12 张。\n\n所以,此看房 VR 图片所占用的内存有:\n\n### ① 常态情况\n\n- PC 端:`2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12= 108MB`\n- iPhone 8:`(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 2= 216MB`\n- iPhone 12:`(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 3= 324MB`\n\n此处分析的这还仅仅是一个实景 VR 依赖图片占用的内存。\n\n### ② 走点 moveToPano\n\n由于走点为了过渡动画效果,一般会出现两个立方体全景,所以全景图片由 6 张图片变成 12 张。\n\n- PC 端:`2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12= 204MB`\n- iPhone 8:`(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*2= 408MB`\n- iPhone 12:`(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*3= 612MB`\n\n看此数据,基本解释:\n\n- 高端 iOS 设备比低端 iOS 设备更容易出现黑白屏内存溢出问题。(iOS 端 WebView 内存崩溃的阈值在 1.5G 以下)。\n- 全景走点时**更加容易**内存溢出。\n- 除了图片占用内存之外,`Five` 涉及的其他部分其实并没有占用过多内存。(也就意味着图片之外的优化空间不多)。\n\n## 序列帧动画\n\n如图五所示,这是一个如视 Logo 组成的循环关键帧动画:\n\n<figure>\n <div style=\"display:flex;flex-direction: column;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><image style=\"width:100%;;\" src=\"//solome.js.org/static/memory-usage-pic/animation.13cc0efb.png\" /></div>\n <div style=\"flex: 1\"><image style=\"width:100%;max-width: 140px;\" src=\"//solome.js.org/static/memory-usage-pic/realseelogo.gif\" /></div>\n </div>\n <figcaption>图四:关键帧Sprite图和逐帧动画</figcaption>\n</figure>\n\n这张帧动画雪碧图分辨率是`14065*265`,占用内存:\n\n- PC 端:`14065*265*4/(1024*1024)=14.21823501586914MB`\n- iPhone 8:`14065*265*4/(1024*1024)*2= 28.43647003173828MB`\n- iPhone 12:`14065*265*4/(1024*1024)*3=42.65470504760742MB`\n\n将这张雪碧图放在`<image>`标签中确实是这样的内存占用。但是,一旦套用 CSS 帧动画实现之后:\n\n```css\n@keyframes logo-sprites-animation {\n 0% {\n background-position: 0 0;\n }\n 100% {\n background-position: 13800px 0;\n }\n}\n\nanimation: logo-sprites-animation 2.208s 0s steps(53) infinite normal;\n```\n\n通过 PerfDog 统计的内存占用却是图片内存的三倍:\n\n- PC 端:`14065*265*4/(1024*1024)*3=42.65470504760742MB`\n- iPhone 8 端:`14065*265*4/(1024*1024)*2*3= 85.30941009521484MB`\n- iPhone 12 端:`14065*265*4/(1024*1024)*3*3=127.96411514282227MB`\n\n这个三倍是怎么来的,目前尚未找到相关资料,个人猜测的逻辑是:\n此处的逐帧动画本质上是个补间动画,用在帧动画中,需要上一帧、当前帧、下一帧 来计算补间动画,同时需要三张图片,所以可能会同时存在三张图片实例。\n\n这个目前尚属猜测逻辑。但需要关注的经验是:**逐帧动画慎用,帧数最好限制在 24 帧以内,且占用内存不要超过 20MB。**\n\n## 最后\n\n有兴趣的同学,可以安装 [**PerfDog 性能狗**](https://perfdog.qq.com/) 工具自己实践一下本文的数据是否存在偏差。\n","source":"_posts/memory-usage-pic.md","raw":"---\nlayout: layout/post\ntitle: '前端内存分析之图片篇'\ndate: 2022-03-11 23:30:00 +0800\ncomments: true\ncategories: 技术总结\n---\n\n## 背景\n\n二零年年末,我所在如视的前端团队针对核心 C 端项目 **VR 3D 看房** 做了次从 2.0 到 3.0 的系统重构——交互风格、前端架构等等都重新整了遍。灰度阶段前,通过[ PerfDog 性能狗](https://perfdog.qq.com/) 性能分析发现:我们一个 VR 3D 页面在 PC 端占用 120MB 左右内存,在 iPhone 12 上竟然高达 360MB。\n\n在加上业务能力的升级——除了传统实景 VR 之外,我们还新增了虚拟 VR 用以展示房源装修前后的效果对比,这又新增了一个 VR 实例,内存占用已超 700MB。\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/memory-usage-pic/memo-vr.png\" /></div>\n </div>\n <figcaption>图一:2.0和3.0 内存占用情况</figcaption>\n</figure>\n\n此外,随着用户的交互(开启地图、逐帧动画等),内存还在不断递增,高峰期已经超过 1G。而 iOS 系统 WebView 内存溢出的阈值最高也才 1.5G,VR 页面已经濒临崩溃。\n\n很好奇为啥会占用了那么多的内存?让我们来简单探究一下吧。\n\n## 图片内存占用\n\n三维模型一般由面片数据(顶点、线)和贴图组成,内存占用的大头是图片。那一张图片渲染至浏览器占用的内存该怎么计算呢?\n\n一般浏览器渲染图片**BitMap**选用的是 [ARGB_8888](https://en.wikipedia.org/wiki/RGBA_color_model#RGBA8888):颜色信息由透明度 A(Alpha)与 R(Red),G(Green),B(Blue)四部分组成,每个部分都占 8 位,总共占 32 位。即一个像素:\n\n> - **A** - alpha 透明 8bit(位)\n> - **R** - Red 8bit(位)\n> - **G** - Green 8bit(位)\n> - **B** - Blue 8bit(位)\n\n\"1Byte(字节)=8bit(位)\" 因此,**一个像素会占用四个字节**。\n所以一张 `2048*2048` 的图片占用的内存有:`2048*2048*4 Byte`,换算成 MB 单位 `2048*2048*4/ (1024*1024) Byte = 16MB`。\n\n> 图片占用的内存跟图片文件体积大小无关,仅跟其分辨率相关。压缩图片目的是为了 CDN 下载速度更快、节省存储空间,但无法节省浏览器占用内存。\n\n**\"一个像素会占用四个字节\"** 这个结论适用于绝大部分 PC、macOS 等终端设备,但在移动端并不完全适用,详细内容请往下看。\n\n## 终端设备\n\n以 iPhone 为例,先统计下历代 iPhone 屏幕信息:\n\n| 机型 | 逻辑像素 | 渲染像素 | 物理像素 | 设备像素比 DPR | 一个像素用几个字节 |\n| :------------------------: | :------: | :--------: | :--------: | :------------: | :-----------------: |\n| iPhone 3G/3Gs | 320\\*480 | 320\\*480 | 320\\*480 | 1 | 4 个字节 |\n| iPhone 4/4s | 320\\*480 | 640\\*960 | 640\\*960 | 2 | 4 \\* 2 个字节 |\n| iPhone 5/5C/5s/SE | 320\\*568 | 640\\*1136 | 640\\*1136 | 2 | 4 \\* 2 个字节 |\n| iPhone 6/6s/7/8/SE2 | 375\\*667 | 750\\*1334 | 750\\*1334 | 2 | 4 \\* 2 个字节 |\n| iPhone XR/11 | 414\\*896 | 828\\*1792 | 828\\*1792 | 2 | 4 \\* 2 个字节 |\n| iPhone X/Xs/11 Pro | 375\\*812 | 1125\\*2436 | 1125\\*2436 | 3 | 4 \\* 3 个字节 |\n| iPhone 12 mini | 375\\*812 | 1125\\*2436 | 1080\\*2340 | 3 | 约 4 \\* 2.88 个字节 |\n| iPhone 12/12 Pro | 390\\*844 | 1170\\*2532 | 1170\\*2532 | 3 | 4 \\* 3 个字节 |\n| iPhone 6/6s/7/8/ Plus | 414\\*736 | 1242\\*2208 | 1080\\*1920 | 3 | 约 4 \\* 2.61 个字节 |\n| iPhone Xs Max / 11 Pro Max | 414\\*896 | 1242\\*2688 | 1242\\*2688 | 3 | 4 \\* 3 个字节 |\n| iPhone 12 Pro Max | 428\\*926 | 1284\\*2778 | 1284\\*2778 | 3 | 4 \\* 3 个字节 |\n\n> - **物理像素**:硬件真实的像素,即屏幕分辨率。\n> - **逻辑像素**:前端使用的像素,即 `px`。\n> - **渲染像素**:操作系统抽象的像素。\n\n从 iPhone 4 代开始,iPhone 屏幕的物理分辨率是很高的,除了 \"iPhone 6/6s/7/8/ Plus\" 和 \"iPhone 12 mini\" 设备之外,iOS 系统基本是把 2 个或 3 个物理像素当作 1 个逻辑像素来使用的(放大倍数了)。\n\nAndroid 系统则比较凌乱,但本质还是**将多个物理像素当作一个逻辑像素来渲染使用**。因此,一张`2048*2048`图片内存占用换算公式是: `(物理分辨率/逻辑像素)*2048*2048*4/ (1024*1024) MB`。\n\n这基本解释了移动端设备图片占用的内存要比 PC 上统计的要多出 2 倍、3 倍甚至 4 倍以上。这也解释了明明是旗舰机型崩溃率反而增加了,比如 iOS 系统 WebView 内存崩溃的阈值固定在 1.5G 以下,旗舰机型 iPhone 12 Pro Max 更加容易达到这个阈值。\n\n> 介于设备屏幕 LCD、OLED 等材质差异,实际统计会有些许偏差,但是数量级不会有太多出入。\n\n## Five 实例内存占用\n\n[`@realsee/five`](https://realsee.js.org/docs/front/3d-space/get-started/rendering-engine) 是如视基于 Three.js 实现的在浏览器环境中运行的**三维空间渲染引擎**。创建 `Five` 实例并渲染一个三维空间需要耗费多少内存呢?\n\n常态情况下,`Five` 渲染依赖的图片是三维模型的 UV 贴图和一个立方体全景贴图(立方体六个面六张图),如图二、三所示。\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:60%;\" src=\"//solome.js.org/static/memory-usage-pic/pano.png\" /></div>\n </div>\n <figcaption>图二:立方体全景贴图(2048*2048)</figcaption>\n</figure>\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:80%;\" src=\"//solome.js.org/static/memory-usage-pic/model.png\" /></div>\n </div>\n <figcaption>图三:UV 贴图及网格数据组成模型(512*512)</figcaption>\n</figure>\n\n因此,我们以[贝壳·VR 看房 | 常楹公元 2 室 1 厅](https://open.realsee.com/ke/6gyq3v1verxD7JO1/qeNadDJvp5oSPhzhbTo7mVEC3LM4rOA2/?v3=1) 房源为例,其实景 VR 的 UV 贴图有 12 张。\n\n所以,此看房 VR 图片所占用的内存有:\n\n### ① 常态情况\n\n- PC 端:`2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12= 108MB`\n- iPhone 8:`(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 2= 216MB`\n- iPhone 12:`(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 3= 324MB`\n\n此处分析的这还仅仅是一个实景 VR 依赖图片占用的内存。\n\n### ② 走点 moveToPano\n\n由于走点为了过渡动画效果,一般会出现两个立方体全景,所以全景图片由 6 张图片变成 12 张。\n\n- PC 端:`2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12= 204MB`\n- iPhone 8:`(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*2= 408MB`\n- iPhone 12:`(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*3= 612MB`\n\n看此数据,基本解释:\n\n- 高端 iOS 设备比低端 iOS 设备更容易出现黑白屏内存溢出问题。(iOS 端 WebView 内存崩溃的阈值在 1.5G 以下)。\n- 全景走点时**更加容易**内存溢出。\n- 除了图片占用内存之外,`Five` 涉及的其他部分其实并没有占用过多内存。(也就意味着图片之外的优化空间不多)。\n\n## 序列帧动画\n\n如图五所示,这是一个如视 Logo 组成的循环关键帧动画:\n\n<figure>\n <div style=\"display:flex;flex-direction: column;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><image style=\"width:100%;;\" src=\"//solome.js.org/static/memory-usage-pic/animation.13cc0efb.png\" /></div>\n <div style=\"flex: 1\"><image style=\"width:100%;max-width: 140px;\" src=\"//solome.js.org/static/memory-usage-pic/realseelogo.gif\" /></div>\n </div>\n <figcaption>图四:关键帧Sprite图和逐帧动画</figcaption>\n</figure>\n\n这张帧动画雪碧图分辨率是`14065*265`,占用内存:\n\n- PC 端:`14065*265*4/(1024*1024)=14.21823501586914MB`\n- iPhone 8:`14065*265*4/(1024*1024)*2= 28.43647003173828MB`\n- iPhone 12:`14065*265*4/(1024*1024)*3=42.65470504760742MB`\n\n将这张雪碧图放在`<image>`标签中确实是这样的内存占用。但是,一旦套用 CSS 帧动画实现之后:\n\n```css\n@keyframes logo-sprites-animation {\n 0% {\n background-position: 0 0;\n }\n 100% {\n background-position: 13800px 0;\n }\n}\n\nanimation: logo-sprites-animation 2.208s 0s steps(53) infinite normal;\n```\n\n通过 PerfDog 统计的内存占用却是图片内存的三倍:\n\n- PC 端:`14065*265*4/(1024*1024)*3=42.65470504760742MB`\n- iPhone 8 端:`14065*265*4/(1024*1024)*2*3= 85.30941009521484MB`\n- iPhone 12 端:`14065*265*4/(1024*1024)*3*3=127.96411514282227MB`\n\n这个三倍是怎么来的,目前尚未找到相关资料,个人猜测的逻辑是:\n此处的逐帧动画本质上是个补间动画,用在帧动画中,需要上一帧、当前帧、下一帧 来计算补间动画,同时需要三张图片,所以可能会同时存在三张图片实例。\n\n这个目前尚属猜测逻辑。但需要关注的经验是:**逐帧动画慎用,帧数最好限制在 24 帧以内,且占用内存不要超过 20MB。**\n\n## 最后\n\n有兴趣的同学,可以安装 [**PerfDog 性能狗**](https://perfdog.qq.com/) 工具自己实践一下本文的数据是否存在偏差。\n","slug":"memory-usage-pic","published":1,"updated":"2023-11-07T16:00:23.760Z","_id":"clooia2ly0004ln3yfrsg5p40","photos":[],"link":"","content":"<h2 id=\"背景\"><a href=\"#背景\" class=\"headerlink\" title=\"背景\"></a>背景</h2><p>二零年年末,我所在如视的前端团队针对核心 C 端项目 <strong>VR 3D 看房</strong> 做了次从 2.0 到 3.0 的系统重构——交互风格、前端架构等等都重新整了遍。灰度阶段前,通过<a href=\"https://perfdog.qq.com/\"> PerfDog 性能狗</a> 性能分析发现:我们一个 VR 3D 页面在 PC 端占用 120MB 左右内存,在 iPhone 12 上竟然高达 360MB。</p>\n<p>在加上业务能力的升级——除了传统实景 VR 之外,我们还新增了虚拟 VR 用以展示房源装修前后的效果对比,这又新增了一个 VR 实例,内存占用已超 700MB。</p>\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/memory-usage-pic/memo-vr.png\" /></div>\n </div>\n <figcaption>图一:2.0和3.0 内存占用情况</figcaption>\n</figure>\n\n<p>此外,随着用户的交互(开启地图、逐帧动画等),内存还在不断递增,高峰期已经超过 1G。而 iOS 系统 WebView 内存溢出的阈值最高也才 1.5G,VR 页面已经濒临崩溃。</p>\n<p>很好奇为啥会占用了那么多的内存?让我们来简单探究一下吧。</p>\n<h2 id=\"图片内存占用\"><a href=\"#图片内存占用\" class=\"headerlink\" title=\"图片内存占用\"></a>图片内存占用</h2><p>三维模型一般由面片数据(顶点、线)和贴图组成,内存占用的大头是图片。那一张图片渲染至浏览器占用的内存该怎么计算呢?</p>\n<p>一般浏览器渲染图片<strong>BitMap</strong>选用的是 <a href=\"https://en.wikipedia.org/wiki/RGBA_color_model#RGBA8888\">ARGB_8888</a>:颜色信息由透明度 A(Alpha)与 R(Red),G(Green),B(Blue)四部分组成,每个部分都占 8 位,总共占 32 位。即一个像素:</p>\n<blockquote>\n<ul>\n<li><strong>A</strong> - alpha 透明 8bit(位)</li>\n<li><strong>R</strong> - Red 8bit(位)</li>\n<li><strong>G</strong> - Green 8bit(位)</li>\n<li><strong>B</strong> - Blue 8bit(位)</li>\n</ul>\n</blockquote>\n<p>"1Byte(字节)=8bit(位)" 因此,<strong>一个像素会占用四个字节</strong>。<br>所以一张 <code>2048*2048</code> 的图片占用的内存有:<code>2048*2048*4 Byte</code>,换算成 MB 单位 <code>2048*2048*4/ (1024*1024) Byte = 16MB</code>。</p>\n<blockquote>\n<p>图片占用的内存跟图片文件体积大小无关,仅跟其分辨率相关。压缩图片目的是为了 CDN 下载速度更快、节省存储空间,但无法节省浏览器占用内存。</p>\n</blockquote>\n<p><strong>"一个像素会占用四个字节"</strong> 这个结论适用于绝大部分 PC、macOS 等终端设备,但在移动端并不完全适用,详细内容请往下看。</p>\n<h2 id=\"终端设备\"><a href=\"#终端设备\" class=\"headerlink\" title=\"终端设备\"></a>终端设备</h2><p>以 iPhone 为例,先统计下历代 iPhone 屏幕信息:</p>\n<table>\n<thead>\n<tr>\n<th align=\"center\">机型</th>\n<th align=\"center\">逻辑像素</th>\n<th align=\"center\">渲染像素</th>\n<th align=\"center\">物理像素</th>\n<th align=\"center\">设备像素比 DPR</th>\n<th align=\"center\">一个像素用几个字节</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">iPhone 3G/3Gs</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">1</td>\n<td align=\"center\">4 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 4/4s</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">640*960</td>\n<td align=\"center\">640*960</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 5/5C/5s/SE</td>\n<td align=\"center\">320*568</td>\n<td align=\"center\">640*1136</td>\n<td align=\"center\">640*1136</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 6/6s/7/8/SE2</td>\n<td align=\"center\">375*667</td>\n<td align=\"center\">750*1334</td>\n<td align=\"center\">750*1334</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone XR/11</td>\n<td align=\"center\">414*896</td>\n<td align=\"center\">828*1792</td>\n<td align=\"center\">828*1792</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone X/Xs/11 Pro</td>\n<td align=\"center\">375*812</td>\n<td align=\"center\">1125*2436</td>\n<td align=\"center\">1125*2436</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 12 mini</td>\n<td align=\"center\">375*812</td>\n<td align=\"center\">1125*2436</td>\n<td align=\"center\">1080*2340</td>\n<td align=\"center\">3</td>\n<td align=\"center\">约 4 * 2.88 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 12/12 Pro</td>\n<td align=\"center\">390*844</td>\n<td align=\"center\">1170*2532</td>\n<td align=\"center\">1170*2532</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 6/6s/7/8/ Plus</td>\n<td align=\"center\">414*736</td>\n<td align=\"center\">1242*2208</td>\n<td align=\"center\">1080*1920</td>\n<td align=\"center\">3</td>\n<td align=\"center\">约 4 * 2.61 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone Xs Max / 11 Pro Max</td>\n<td align=\"center\">414*896</td>\n<td align=\"center\">1242*2688</td>\n<td align=\"center\">1242*2688</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 12 Pro Max</td>\n<td align=\"center\">428*926</td>\n<td align=\"center\">1284*2778</td>\n<td align=\"center\">1284*2778</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n</tbody></table>\n<blockquote>\n<ul>\n<li><strong>物理像素</strong>:硬件真实的像素,即屏幕分辨率。</li>\n<li><strong>逻辑像素</strong>:前端使用的像素,即 <code>px</code>。</li>\n<li><strong>渲染像素</strong>:操作系统抽象的像素。</li>\n</ul>\n</blockquote>\n<p>从 iPhone 4 代开始,iPhone 屏幕的物理分辨率是很高的,除了 "iPhone 6/6s/7/8/ Plus" 和 "iPhone 12 mini" 设备之外,iOS 系统基本是把 2 个或 3 个物理像素当作 1 个逻辑像素来使用的(放大倍数了)。</p>\n<p>Android 系统则比较凌乱,但本质还是<strong>将多个物理像素当作一个逻辑像素来渲染使用</strong>。因此,一张<code>2048*2048</code>图片内存占用换算公式是: <code>(物理分辨率/逻辑像素)*2048*2048*4/ (1024*1024) MB</code>。</p>\n<p>这基本解释了移动端设备图片占用的内存要比 PC 上统计的要多出 2 倍、3 倍甚至 4 倍以上。这也解释了明明是旗舰机型崩溃率反而增加了,比如 iOS 系统 WebView 内存崩溃的阈值固定在 1.5G 以下,旗舰机型 iPhone 12 Pro Max 更加容易达到这个阈值。</p>\n<blockquote>\n<p>介于设备屏幕 LCD、OLED 等材质差异,实际统计会有些许偏差,但是数量级不会有太多出入。</p>\n</blockquote>\n<h2 id=\"Five-实例内存占用\"><a href=\"#Five-实例内存占用\" class=\"headerlink\" title=\"Five 实例内存占用\"></a>Five 实例内存占用</h2><p><a href=\"https://realsee.js.org/docs/front/3d-space/get-started/rendering-engine\"><code>@realsee/five</code></a> 是如视基于 Three.js 实现的在浏览器环境中运行的<strong>三维空间渲染引擎</strong>。创建 <code>Five</code> 实例并渲染一个三维空间需要耗费多少内存呢?</p>\n<p>常态情况下,<code>Five</code> 渲染依赖的图片是三维模型的 UV 贴图和一个立方体全景贴图(立方体六个面六张图),如图二、三所示。</p>\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:60%;\" src=\"//solome.js.org/static/memory-usage-pic/pano.png\" /></div>\n </div>\n <figcaption>图二:立方体全景贴图(2048*2048)</figcaption>\n</figure>\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:80%;\" src=\"//solome.js.org/static/memory-usage-pic/model.png\" /></div>\n </div>\n <figcaption>图三:UV 贴图及网格数据组成模型(512*512)</figcaption>\n</figure>\n\n<p>因此,我们以<a href=\"https://open.realsee.com/ke/6gyq3v1verxD7JO1/qeNadDJvp5oSPhzhbTo7mVEC3LM4rOA2/?v3=1\">贝壳·VR 看房 | 常楹公元 2 室 1 厅</a> 房源为例,其实景 VR 的 UV 贴图有 12 张。</p>\n<p>所以,此看房 VR 图片所占用的内存有:</p>\n<h3 id=\"①-常态情况\"><a href=\"#①-常态情况\" class=\"headerlink\" title=\"① 常态情况\"></a>① 常态情况</h3><ul>\n<li>PC 端:<code>2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12= 108MB</code></li>\n<li>iPhone 8:<code>(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 2= 216MB</code></li>\n<li>iPhone 12:<code>(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 3= 324MB</code></li>\n</ul>\n<p>此处分析的这还仅仅是一个实景 VR 依赖图片占用的内存。</p>\n<h3 id=\"②-走点-moveToPano\"><a href=\"#②-走点-moveToPano\" class=\"headerlink\" title=\"② 走点 moveToPano\"></a>② 走点 moveToPano</h3><p>由于走点为了过渡动画效果,一般会出现两个立方体全景,所以全景图片由 6 张图片变成 12 张。</p>\n<ul>\n<li>PC 端:<code>2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12= 204MB</code></li>\n<li>iPhone 8:<code>(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*2= 408MB</code></li>\n<li>iPhone 12:<code>(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*3= 612MB</code></li>\n</ul>\n<p>看此数据,基本解释:</p>\n<ul>\n<li>高端 iOS 设备比低端 iOS 设备更容易出现黑白屏内存溢出问题。(iOS 端 WebView 内存崩溃的阈值在 1.5G 以下)。</li>\n<li>全景走点时<strong>更加容易</strong>内存溢出。</li>\n<li>除了图片占用内存之外,<code>Five</code> 涉及的其他部分其实并没有占用过多内存。(也就意味着图片之外的优化空间不多)。</li>\n</ul>\n<h2 id=\"序列帧动画\"><a href=\"#序列帧动画\" class=\"headerlink\" title=\"序列帧动画\"></a>序列帧动画</h2><p>如图五所示,这是一个如视 Logo 组成的循环关键帧动画:</p>\n<figure>\n <div style=\"display:flex;flex-direction: column;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><image style=\"width:100%;;\" src=\"//solome.js.org/static/memory-usage-pic/animation.13cc0efb.png\" /></div>\n <div style=\"flex: 1\"><image style=\"width:100%;max-width: 140px;\" src=\"//solome.js.org/static/memory-usage-pic/realseelogo.gif\" /></div>\n </div>\n <figcaption>图四:关键帧Sprite图和逐帧动画</figcaption>\n</figure>\n\n<p>这张帧动画雪碧图分辨率是<code>14065*265</code>,占用内存:</p>\n<ul>\n<li>PC 端:<code>14065*265*4/(1024*1024)=14.21823501586914MB</code></li>\n<li>iPhone 8:<code>14065*265*4/(1024*1024)*2= 28.43647003173828MB</code></li>\n<li>iPhone 12:<code>14065*265*4/(1024*1024)*3=42.65470504760742MB</code></li>\n</ul>\n<p>将这张雪碧图放在<code><image></code>标签中确实是这样的内存占用。但是,一旦套用 CSS 帧动画实现之后:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-keyword\">@keyframes</span> logo-sprites-animation {<br> <span class=\"hljs-number\">0%</span> {<br> <span class=\"hljs-attribute\">background-position</span>: <span class=\"hljs-number\">0</span> <span class=\"hljs-number\">0</span>;<br> }<br> <span class=\"hljs-number\">100%</span> {<br> <span class=\"hljs-attribute\">background-position</span>: <span class=\"hljs-number\">13800px</span> <span class=\"hljs-number\">0</span>;<br> }<br>}<br><br><span class=\"hljs-attribute\">animation</span>: logo-sprites-animation <span class=\"hljs-number\">2.208s</span> <span class=\"hljs-number\">0s</span> <span class=\"hljs-built_in\">steps</span>(<span class=\"hljs-number\">53</span>) infinite normal;<br></code></pre></td></tr></table></figure>\n\n<p>通过 PerfDog 统计的内存占用却是图片内存的三倍:</p>\n<ul>\n<li>PC 端:<code>14065*265*4/(1024*1024)*3=42.65470504760742MB</code></li>\n<li>iPhone 8 端:<code>14065*265*4/(1024*1024)*2*3= 85.30941009521484MB</code></li>\n<li>iPhone 12 端:<code>14065*265*4/(1024*1024)*3*3=127.96411514282227MB</code></li>\n</ul>\n<p>这个三倍是怎么来的,目前尚未找到相关资料,个人猜测的逻辑是:<br>此处的逐帧动画本质上是个补间动画,用在帧动画中,需要上一帧、当前帧、下一帧 来计算补间动画,同时需要三张图片,所以可能会同时存在三张图片实例。</p>\n<p>这个目前尚属猜测逻辑。但需要关注的经验是:<strong>逐帧动画慎用,帧数最好限制在 24 帧以内,且占用内存不要超过 20MB。</strong></p>\n<h2 id=\"最后\"><a href=\"#最后\" class=\"headerlink\" title=\"最后\"></a>最后</h2><p>有兴趣的同学,可以安装 <a href=\"https://perfdog.qq.com/\"><strong>PerfDog 性能狗</strong></a> 工具自己实践一下本文的数据是否存在偏差。</p>\n","site":{"data":{}},"excerpt":"","more":"<h2 id=\"背景\"><a href=\"#背景\" class=\"headerlink\" title=\"背景\"></a>背景</h2><p>二零年年末,我所在如视的前端团队针对核心 C 端项目 <strong>VR 3D 看房</strong> 做了次从 2.0 到 3.0 的系统重构——交互风格、前端架构等等都重新整了遍。灰度阶段前,通过<a href=\"https://perfdog.qq.com/\"> PerfDog 性能狗</a> 性能分析发现:我们一个 VR 3D 页面在 PC 端占用 120MB 左右内存,在 iPhone 12 上竟然高达 360MB。</p>\n<p>在加上业务能力的升级——除了传统实景 VR 之外,我们还新增了虚拟 VR 用以展示房源装修前后的效果对比,这又新增了一个 VR 实例,内存占用已超 700MB。</p>\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:100%;\" src=\"//solome.js.org/static/memory-usage-pic/memo-vr.png\" /></div>\n </div>\n <figcaption>图一:2.0和3.0 内存占用情况</figcaption>\n</figure>\n\n<p>此外,随着用户的交互(开启地图、逐帧动画等),内存还在不断递增,高峰期已经超过 1G。而 iOS 系统 WebView 内存溢出的阈值最高也才 1.5G,VR 页面已经濒临崩溃。</p>\n<p>很好奇为啥会占用了那么多的内存?让我们来简单探究一下吧。</p>\n<h2 id=\"图片内存占用\"><a href=\"#图片内存占用\" class=\"headerlink\" title=\"图片内存占用\"></a>图片内存占用</h2><p>三维模型一般由面片数据(顶点、线)和贴图组成,内存占用的大头是图片。那一张图片渲染至浏览器占用的内存该怎么计算呢?</p>\n<p>一般浏览器渲染图片<strong>BitMap</strong>选用的是 <a href=\"https://en.wikipedia.org/wiki/RGBA_color_model#RGBA8888\">ARGB_8888</a>:颜色信息由透明度 A(Alpha)与 R(Red),G(Green),B(Blue)四部分组成,每个部分都占 8 位,总共占 32 位。即一个像素:</p>\n<blockquote>\n<ul>\n<li><strong>A</strong> - alpha 透明 8bit(位)</li>\n<li><strong>R</strong> - Red 8bit(位)</li>\n<li><strong>G</strong> - Green 8bit(位)</li>\n<li><strong>B</strong> - Blue 8bit(位)</li>\n</ul>\n</blockquote>\n<p>"1Byte(字节)=8bit(位)" 因此,<strong>一个像素会占用四个字节</strong>。<br>所以一张 <code>2048*2048</code> 的图片占用的内存有:<code>2048*2048*4 Byte</code>,换算成 MB 单位 <code>2048*2048*4/ (1024*1024) Byte = 16MB</code>。</p>\n<blockquote>\n<p>图片占用的内存跟图片文件体积大小无关,仅跟其分辨率相关。压缩图片目的是为了 CDN 下载速度更快、节省存储空间,但无法节省浏览器占用内存。</p>\n</blockquote>\n<p><strong>"一个像素会占用四个字节"</strong> 这个结论适用于绝大部分 PC、macOS 等终端设备,但在移动端并不完全适用,详细内容请往下看。</p>\n<h2 id=\"终端设备\"><a href=\"#终端设备\" class=\"headerlink\" title=\"终端设备\"></a>终端设备</h2><p>以 iPhone 为例,先统计下历代 iPhone 屏幕信息:</p>\n<table>\n<thead>\n<tr>\n<th align=\"center\">机型</th>\n<th align=\"center\">逻辑像素</th>\n<th align=\"center\">渲染像素</th>\n<th align=\"center\">物理像素</th>\n<th align=\"center\">设备像素比 DPR</th>\n<th align=\"center\">一个像素用几个字节</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">iPhone 3G/3Gs</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">1</td>\n<td align=\"center\">4 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 4/4s</td>\n<td align=\"center\">320*480</td>\n<td align=\"center\">640*960</td>\n<td align=\"center\">640*960</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 5/5C/5s/SE</td>\n<td align=\"center\">320*568</td>\n<td align=\"center\">640*1136</td>\n<td align=\"center\">640*1136</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 6/6s/7/8/SE2</td>\n<td align=\"center\">375*667</td>\n<td align=\"center\">750*1334</td>\n<td align=\"center\">750*1334</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone XR/11</td>\n<td align=\"center\">414*896</td>\n<td align=\"center\">828*1792</td>\n<td align=\"center\">828*1792</td>\n<td align=\"center\">2</td>\n<td align=\"center\">4 * 2 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone X/Xs/11 Pro</td>\n<td align=\"center\">375*812</td>\n<td align=\"center\">1125*2436</td>\n<td align=\"center\">1125*2436</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 12 mini</td>\n<td align=\"center\">375*812</td>\n<td align=\"center\">1125*2436</td>\n<td align=\"center\">1080*2340</td>\n<td align=\"center\">3</td>\n<td align=\"center\">约 4 * 2.88 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 12/12 Pro</td>\n<td align=\"center\">390*844</td>\n<td align=\"center\">1170*2532</td>\n<td align=\"center\">1170*2532</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 6/6s/7/8/ Plus</td>\n<td align=\"center\">414*736</td>\n<td align=\"center\">1242*2208</td>\n<td align=\"center\">1080*1920</td>\n<td align=\"center\">3</td>\n<td align=\"center\">约 4 * 2.61 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone Xs Max / 11 Pro Max</td>\n<td align=\"center\">414*896</td>\n<td align=\"center\">1242*2688</td>\n<td align=\"center\">1242*2688</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n<tr>\n<td align=\"center\">iPhone 12 Pro Max</td>\n<td align=\"center\">428*926</td>\n<td align=\"center\">1284*2778</td>\n<td align=\"center\">1284*2778</td>\n<td align=\"center\">3</td>\n<td align=\"center\">4 * 3 个字节</td>\n</tr>\n</tbody></table>\n<blockquote>\n<ul>\n<li><strong>物理像素</strong>:硬件真实的像素,即屏幕分辨率。</li>\n<li><strong>逻辑像素</strong>:前端使用的像素,即 <code>px</code>。</li>\n<li><strong>渲染像素</strong>:操作系统抽象的像素。</li>\n</ul>\n</blockquote>\n<p>从 iPhone 4 代开始,iPhone 屏幕的物理分辨率是很高的,除了 "iPhone 6/6s/7/8/ Plus" 和 "iPhone 12 mini" 设备之外,iOS 系统基本是把 2 个或 3 个物理像素当作 1 个逻辑像素来使用的(放大倍数了)。</p>\n<p>Android 系统则比较凌乱,但本质还是<strong>将多个物理像素当作一个逻辑像素来渲染使用</strong>。因此,一张<code>2048*2048</code>图片内存占用换算公式是: <code>(物理分辨率/逻辑像素)*2048*2048*4/ (1024*1024) MB</code>。</p>\n<p>这基本解释了移动端设备图片占用的内存要比 PC 上统计的要多出 2 倍、3 倍甚至 4 倍以上。这也解释了明明是旗舰机型崩溃率反而增加了,比如 iOS 系统 WebView 内存崩溃的阈值固定在 1.5G 以下,旗舰机型 iPhone 12 Pro Max 更加容易达到这个阈值。</p>\n<blockquote>\n<p>介于设备屏幕 LCD、OLED 等材质差异,实际统计会有些许偏差,但是数量级不会有太多出入。</p>\n</blockquote>\n<h2 id=\"Five-实例内存占用\"><a href=\"#Five-实例内存占用\" class=\"headerlink\" title=\"Five 实例内存占用\"></a>Five 实例内存占用</h2><p><a href=\"https://realsee.js.org/docs/front/3d-space/get-started/rendering-engine\"><code>@realsee/five</code></a> 是如视基于 Three.js 实现的在浏览器环境中运行的<strong>三维空间渲染引擎</strong>。创建 <code>Five</code> 实例并渲染一个三维空间需要耗费多少内存呢?</p>\n<p>常态情况下,<code>Five</code> 渲染依赖的图片是三维模型的 UV 贴图和一个立方体全景贴图(立方体六个面六张图),如图二、三所示。</p>\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:60%;\" src=\"//solome.js.org/static/memory-usage-pic/pano.png\" /></div>\n </div>\n <figcaption>图二:立方体全景贴图(2048*2048)</figcaption>\n</figure>\n\n<figure>\n <div style=\"display:flex;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><img style=\"width:80%;\" src=\"//solome.js.org/static/memory-usage-pic/model.png\" /></div>\n </div>\n <figcaption>图三:UV 贴图及网格数据组成模型(512*512)</figcaption>\n</figure>\n\n<p>因此,我们以<a href=\"https://open.realsee.com/ke/6gyq3v1verxD7JO1/qeNadDJvp5oSPhzhbTo7mVEC3LM4rOA2/?v3=1\">贝壳·VR 看房 | 常楹公元 2 室 1 厅</a> 房源为例,其实景 VR 的 UV 贴图有 12 张。</p>\n<p>所以,此看房 VR 图片所占用的内存有:</p>\n<h3 id=\"①-常态情况\"><a href=\"#①-常态情况\" class=\"headerlink\" title=\"① 常态情况\"></a>① 常态情况</h3><ul>\n<li>PC 端:<code>2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12= 108MB</code></li>\n<li>iPhone 8:<code>(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 2= 216MB</code></li>\n<li>iPhone 12:<code>(2048*2048*4/ (1024*1024) *6 + 512*512*4/ (1024*1024)*12) * 3= 324MB</code></li>\n</ul>\n<p>此处分析的这还仅仅是一个实景 VR 依赖图片占用的内存。</p>\n<h3 id=\"②-走点-moveToPano\"><a href=\"#②-走点-moveToPano\" class=\"headerlink\" title=\"② 走点 moveToPano\"></a>② 走点 moveToPano</h3><p>由于走点为了过渡动画效果,一般会出现两个立方体全景,所以全景图片由 6 张图片变成 12 张。</p>\n<ul>\n<li>PC 端:<code>2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12= 204MB</code></li>\n<li>iPhone 8:<code>(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*2= 408MB</code></li>\n<li>iPhone 12:<code>(2048*2048*4/(1024*1024)*6*2 + 512*512*4/ (1024*1024)*12)*3= 612MB</code></li>\n</ul>\n<p>看此数据,基本解释:</p>\n<ul>\n<li>高端 iOS 设备比低端 iOS 设备更容易出现黑白屏内存溢出问题。(iOS 端 WebView 内存崩溃的阈值在 1.5G 以下)。</li>\n<li>全景走点时<strong>更加容易</strong>内存溢出。</li>\n<li>除了图片占用内存之外,<code>Five</code> 涉及的其他部分其实并没有占用过多内存。(也就意味着图片之外的优化空间不多)。</li>\n</ul>\n<h2 id=\"序列帧动画\"><a href=\"#序列帧动画\" class=\"headerlink\" title=\"序列帧动画\"></a>序列帧动画</h2><p>如图五所示,这是一个如视 Logo 组成的循环关键帧动画:</p>\n<figure>\n <div style=\"display:flex;flex-direction: column;\" class=\"fancyboxflex\">\n <div style=\"flex: 1\"><image style=\"width:100%;;\" src=\"//solome.js.org/static/memory-usage-pic/animation.13cc0efb.png\" /></div>\n <div style=\"flex: 1\"><image style=\"width:100%;max-width: 140px;\" src=\"//solome.js.org/static/memory-usage-pic/realseelogo.gif\" /></div>\n </div>\n <figcaption>图四:关键帧Sprite图和逐帧动画</figcaption>\n</figure>\n\n<p>这张帧动画雪碧图分辨率是<code>14065*265</code>,占用内存:</p>\n<ul>\n<li>PC 端:<code>14065*265*4/(1024*1024)=14.21823501586914MB</code></li>\n<li>iPhone 8:<code>14065*265*4/(1024*1024)*2= 28.43647003173828MB</code></li>\n<li>iPhone 12:<code>14065*265*4/(1024*1024)*3=42.65470504760742MB</code></li>\n</ul>\n<p>将这张雪碧图放在<code><image></code>标签中确实是这样的内存占用。但是,一旦套用 CSS 帧动画实现之后:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-keyword\">@keyframes</span> logo-sprites-animation {<br> <span class=\"hljs-number\">0%</span> {<br> <span class=\"hljs-attribute\">background-position</span>: <span class=\"hljs-number\">0</span> <span class=\"hljs-number\">0</span>;<br> }<br> <span class=\"hljs-number\">100%</span> {<br> <span class=\"hljs-attribute\">background-position</span>: <span class=\"hljs-number\">13800px</span> <span class=\"hljs-number\">0</span>;<br> }<br>}<br><br><span class=\"hljs-attribute\">animation</span>: logo-sprites-animation <span class=\"hljs-number\">2.208s</span> <span class=\"hljs-number\">0s</span> <span class=\"hljs-built_in\">steps</span>(<span class=\"hljs-number\">53</span>) infinite normal;<br></code></pre></td></tr></table></figure>\n\n<p>通过 PerfDog 统计的内存占用却是图片内存的三倍:</p>\n<ul>\n<li>PC 端:<code>14065*265*4/(1024*1024)*3=42.65470504760742MB</code></li>\n<li>iPhone 8 端:<code>14065*265*4/(1024*1024)*2*3= 85.30941009521484MB</code></li>\n<li>iPhone 12 端:<code>14065*265*4/(1024*1024)*3*3=127.96411514282227MB</code></li>\n</ul>\n<p>这个三倍是怎么来的,目前尚未找到相关资料,个人猜测的逻辑是:<br>此处的逐帧动画本质上是个补间动画,用在帧动画中,需要上一帧、当前帧、下一帧 来计算补间动画,同时需要三张图片,所以可能会同时存在三张图片实例。</p>\n<p>这个目前尚属猜测逻辑。但需要关注的经验是:<strong>逐帧动画慎用,帧数最好限制在 24 帧以内,且占用内存不要超过 20MB。</strong></p>\n<h2 id=\"最后\"><a href=\"#最后\" class=\"headerlink\" title=\"最后\"></a>最后</h2><p>有兴趣的同学,可以安装 <a href=\"https://perfdog.qq.com/\"><strong>PerfDog 性能狗</strong></a> 工具自己实践一下本文的数据是否存在偏差。</p>\n"},{"layout":"layout/post","title":"函数式编程之纯函数","date":"2016-09-07T07:18:22.000Z","comments":1,"_content":"\n数学上的函数指的是两个集合间的一种特殊的映射关系。这个特殊体现在什么地方呢?\n\n我们将集合`A`的元素称呼为输入值,集合`B`的元素称呼为输出值,且集合`A`、`B`存在这样的映射关系:**每个输入值只会映射一个输出值,不同的输入值可以映射相同的输出值,不会出现同一个输入值映射不同的输出值**。\n\n比如,下图集合`A`和集合`B`的映射关系即符合数学函数的定义。\n\n<figure>\n <img style=\"width: auto;\" src=\"//solome.js.org/static/pure-functions-in-fp/set_map.svg\" alt=\"containing block\" />\n <figcaption>fn:除以5的余数</figcaption>\n</figure>\n\n> 在函数式编程语言中,满足这种数学意义上的函数即为纯函数(Pure Function):相同的输入(参数),永远得到的是相同的输出(返回值),并且没有任何可观察的\"副作用\"。 \n> 自然,与纯函数的概念相反的函数(即相同参数的函数在不同环境或时机调用得到的返回值不一致)叫做非纯函数(Impure Function)。\n\n### 关于**函数副作用**(side-effect)\n\n**函数副作用**指当调用函数时,在计算返回值数值的过程中,对主调用函数产生附加的影响。\n\n#### 更高作用域的变量\"悄悄\"发生变更\n\n```js\nlet glob = 1\nfunction foo(x) {\n return ++glob + x\n}\nconsole.log(foo(1)) // => 3\n```\n\n变量`glob`的值随着`foo()`的调用发生变化,表现得很不明显。\n\n#### \"隐晦\"地修改了引用参数\n\n```js\nlet glob = 1\nconst obj = { glob }\nfunction foo(x) {\n return ++x.glob\n}\nfoo(obj)\nconsole.log(glob) // => 2\n```\n\n虽然对象`obj`定义为`const`,但是修改了间接引用的变量`glob`;这种场景引发的 bug 其实是很难捕获的(尤其是具备指针概念的 C/C++语言)。\n\n函数副作用确实是滋生 Bug 的\"温床\",造成的问题一般都很\"隐晦\";有些开发场景中,我们其实也无法避免函数的副作用(典型的例子是读写数据库操作的函数)。最好的做法是,要将这些副作用限制在可控的范围内。\n\n### 纯函数带来的好处\n\n#### 函数调用结果可缓存\n\n相同参数得到的返回值是相同的。如果通过参数获取返回值的过程计算量过大,我们可以缓存函数调用的结果,避免相同参数为了获取返回值进行重复计算。典型的实践是对递归函数做性能优化的`memoize`技术。\n以`fibonacci(n)`递归函数为例,传统的实现:\n\n```js\nfunction fibonacci(n) {\n if (n === 0 || n === 1) return n\n return fibonacci(n - 1) + fibonacci(n - 2)\n}\n\nconsole.log(fibonacci(10))\n```\n\n计算的复杂度以参数`n`呈指数级增长:\n\n```haskell\nf(0) = 0\nf(1) = 1\nf(2) = f(1) + f(0) = 1\nf(3) = f(2) + f(1) = 2\nf(4) = f(3) + f(2)\n = f(2) + f(1) + f(2) = 3\nf(5) = f(4) + f(3)\n = f(3) + f(2) + f(2) + f(1)\n = f(2) + f(1) + f(2) + f(2) + f(1) = 5\nf(6) = f(5) + f(4)\n = f(4) + f(3) + f(3) + f(2)\n = f(3) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2)\n = f(2) + f(1) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2) = 8\n... ...\n```\n\n为了获取`fibonacci(n)`的结果,我们不得不将`fibonacci(n-1)`和`fibonacci(n-2)`都得计算一遍;如果我们在调用一次`fibonacci(n)`之后,就将其缓存起来,下次再调用时就无需重新再计算。稍加改造,添加对计算结果的缓存:\n\n```js\nconst fibonacci = (function () {\n const cache = {}\n\n return function fib(n) {\n if (n in cache) return cache[n]\n return (cache[n] = n === 0 || n === 1 ? n : fib(n - 1) + fib(n - 2))\n }\n})()\nconsole.log(fibonacci(10))\n```\n\n这是典型的以空间换效率的优化思路,避免了额外计算的浪费。\n这样实现的前提就是,该递归函数是纯函数,相同参数得到的返回值一定是相同的;如果不能保证相同,我们无法做缓存。\n\n当然,我们可以实现一个`memoize()`函数来统一做缓存这样的工作。\nJavaScript 函数式编程支持库如均提供`memoize()`函数,这里提供一种不太健壮(内存溢出)的实现方案。\n\n```js\nfunction memoize(func) {\n const memo = {}\n const slice = Array.prototype.slice\n\n return function () {\n const args = slice.call(arguments)\n\n if (args in memo) return memo[args]\n return (memo[args] = func.apply(this, args))\n }\n}\n```\n\n> 这样函数调用的次数愈多效率会慢慢变得愈高。\n\n#### 便于移植和测试\n\n纯函数是\"自给自足\"的,所有的函数依赖均由函数自身提供(或参数);因此,我们将一个函数移植到另外一个系统时,是无需考虑成本的\n——当然,如果一个函数依赖一个全局变量,在移植该函数时必须\"慎重\",要将这个全局变量的逻辑一起迁移过去。\n\n相同参数得到的函数返回值是固定的,这一特性也使纯函数更易测试——你无需模拟出一些特殊的测试环境,只要明确定义好函数参数的范围即可。\n\n#### 引用透明(Referential Transparent)\n\n> An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions. \n> —— [https://en.wikipedia.org/wiki/Referential_transparency](https://en.wikipedia.org/wiki/Referential_transparency)\n\n该如何理解呢?可以拿上文提到的`fibonacci()`函数举例,比如存在这样一个函数:\n\n```js\nfunction foo(n, fun) {\n return fun(n) + fun(n)\n}\n\nfoo(10, fibonacci)\n```\n\n调用`foo(10, fibonacci)`会发现`fibonacci(10)`被执行了两遍。因为纯函数具备引用透明性,某些表达式被**替换**并不会改变函数的行为;因此,对`foo()`进行些许变动会使其性能得到质的提升。\n\n```js\nfunction foo(n, fun) {\n return fun(n) * 2\n}\n```\n\n毕竟在此场景中,一次乘法运算成本远比一次`fibonacci(10)`递归运算的成本来得低。\n\n这里的由`fibonacci(n) + fibonacci(n) => 2*fibonacci(n)`转变完全跟数学概念中的`f(x) = x + x = 2 *x`函数推导一致。\n\n因为纯函数的引用透明的特性,我们完全可以将多个函数构成的复杂程序(函数)**推导**成更加简单的方式。\n\n#### 并行代码\n\n纯函数无副作用,同时调用两个函数或同个函数被同时调用两次都不会抢占外部公共资源的情况。\n\n### 总结\n\n- 程序设计中的大部分 Bug 都是有函数副作用引入的,实际开发中必须鼓励纯函数的编写。\n- 在函数式编程范畴中,欲想以函数为基础生成新的函数,那纯函数是这些新函数的基石。\n- 多尝试使用`memoize`技术对递归函数进行性能优化。\n","source":"_posts/pure-functions-in-fp.md","raw":"---\nlayout: layout/post\ntitle: '函数式编程之纯函数'\ndate: 2016-09-07 15:18:22 +0800\ncomments: true\ncategories: 学习札記\n---\n\n数学上的函数指的是两个集合间的一种特殊的映射关系。这个特殊体现在什么地方呢?\n\n我们将集合`A`的元素称呼为输入值,集合`B`的元素称呼为输出值,且集合`A`、`B`存在这样的映射关系:**每个输入值只会映射一个输出值,不同的输入值可以映射相同的输出值,不会出现同一个输入值映射不同的输出值**。\n\n比如,下图集合`A`和集合`B`的映射关系即符合数学函数的定义。\n\n<figure>\n <img style=\"width: auto;\" src=\"//solome.js.org/static/pure-functions-in-fp/set_map.svg\" alt=\"containing block\" />\n <figcaption>fn:除以5的余数</figcaption>\n</figure>\n\n> 在函数式编程语言中,满足这种数学意义上的函数即为纯函数(Pure Function):相同的输入(参数),永远得到的是相同的输出(返回值),并且没有任何可观察的\"副作用\"。 \n> 自然,与纯函数的概念相反的函数(即相同参数的函数在不同环境或时机调用得到的返回值不一致)叫做非纯函数(Impure Function)。\n\n### 关于**函数副作用**(side-effect)\n\n**函数副作用**指当调用函数时,在计算返回值数值的过程中,对主调用函数产生附加的影响。\n\n#### 更高作用域的变量\"悄悄\"发生变更\n\n```js\nlet glob = 1\nfunction foo(x) {\n return ++glob + x\n}\nconsole.log(foo(1)) // => 3\n```\n\n变量`glob`的值随着`foo()`的调用发生变化,表现得很不明显。\n\n#### \"隐晦\"地修改了引用参数\n\n```js\nlet glob = 1\nconst obj = { glob }\nfunction foo(x) {\n return ++x.glob\n}\nfoo(obj)\nconsole.log(glob) // => 2\n```\n\n虽然对象`obj`定义为`const`,但是修改了间接引用的变量`glob`;这种场景引发的 bug 其实是很难捕获的(尤其是具备指针概念的 C/C++语言)。\n\n函数副作用确实是滋生 Bug 的\"温床\",造成的问题一般都很\"隐晦\";有些开发场景中,我们其实也无法避免函数的副作用(典型的例子是读写数据库操作的函数)。最好的做法是,要将这些副作用限制在可控的范围内。\n\n### 纯函数带来的好处\n\n#### 函数调用结果可缓存\n\n相同参数得到的返回值是相同的。如果通过参数获取返回值的过程计算量过大,我们可以缓存函数调用的结果,避免相同参数为了获取返回值进行重复计算。典型的实践是对递归函数做性能优化的`memoize`技术。\n以`fibonacci(n)`递归函数为例,传统的实现:\n\n```js\nfunction fibonacci(n) {\n if (n === 0 || n === 1) return n\n return fibonacci(n - 1) + fibonacci(n - 2)\n}\n\nconsole.log(fibonacci(10))\n```\n\n计算的复杂度以参数`n`呈指数级增长:\n\n```haskell\nf(0) = 0\nf(1) = 1\nf(2) = f(1) + f(0) = 1\nf(3) = f(2) + f(1) = 2\nf(4) = f(3) + f(2)\n = f(2) + f(1) + f(2) = 3\nf(5) = f(4) + f(3)\n = f(3) + f(2) + f(2) + f(1)\n = f(2) + f(1) + f(2) + f(2) + f(1) = 5\nf(6) = f(5) + f(4)\n = f(4) + f(3) + f(3) + f(2)\n = f(3) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2)\n = f(2) + f(1) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2) = 8\n... ...\n```\n\n为了获取`fibonacci(n)`的结果,我们不得不将`fibonacci(n-1)`和`fibonacci(n-2)`都得计算一遍;如果我们在调用一次`fibonacci(n)`之后,就将其缓存起来,下次再调用时就无需重新再计算。稍加改造,添加对计算结果的缓存:\n\n```js\nconst fibonacci = (function () {\n const cache = {}\n\n return function fib(n) {\n if (n in cache) return cache[n]\n return (cache[n] = n === 0 || n === 1 ? n : fib(n - 1) + fib(n - 2))\n }\n})()\nconsole.log(fibonacci(10))\n```\n\n这是典型的以空间换效率的优化思路,避免了额外计算的浪费。\n这样实现的前提就是,该递归函数是纯函数,相同参数得到的返回值一定是相同的;如果不能保证相同,我们无法做缓存。\n\n当然,我们可以实现一个`memoize()`函数来统一做缓存这样的工作。\nJavaScript 函数式编程支持库如均提供`memoize()`函数,这里提供一种不太健壮(内存溢出)的实现方案。\n\n```js\nfunction memoize(func) {\n const memo = {}\n const slice = Array.prototype.slice\n\n return function () {\n const args = slice.call(arguments)\n\n if (args in memo) return memo[args]\n return (memo[args] = func.apply(this, args))\n }\n}\n```\n\n> 这样函数调用的次数愈多效率会慢慢变得愈高。\n\n#### 便于移植和测试\n\n纯函数是\"自给自足\"的,所有的函数依赖均由函数自身提供(或参数);因此,我们将一个函数移植到另外一个系统时,是无需考虑成本的\n——当然,如果一个函数依赖一个全局变量,在移植该函数时必须\"慎重\",要将这个全局变量的逻辑一起迁移过去。\n\n相同参数得到的函数返回值是固定的,这一特性也使纯函数更易测试——你无需模拟出一些特殊的测试环境,只要明确定义好函数参数的范围即可。\n\n#### 引用透明(Referential Transparent)\n\n> An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions. \n> —— [https://en.wikipedia.org/wiki/Referential_transparency](https://en.wikipedia.org/wiki/Referential_transparency)\n\n该如何理解呢?可以拿上文提到的`fibonacci()`函数举例,比如存在这样一个函数:\n\n```js\nfunction foo(n, fun) {\n return fun(n) + fun(n)\n}\n\nfoo(10, fibonacci)\n```\n\n调用`foo(10, fibonacci)`会发现`fibonacci(10)`被执行了两遍。因为纯函数具备引用透明性,某些表达式被**替换**并不会改变函数的行为;因此,对`foo()`进行些许变动会使其性能得到质的提升。\n\n```js\nfunction foo(n, fun) {\n return fun(n) * 2\n}\n```\n\n毕竟在此场景中,一次乘法运算成本远比一次`fibonacci(10)`递归运算的成本来得低。\n\n这里的由`fibonacci(n) + fibonacci(n) => 2*fibonacci(n)`转变完全跟数学概念中的`f(x) = x + x = 2 *x`函数推导一致。\n\n因为纯函数的引用透明的特性,我们完全可以将多个函数构成的复杂程序(函数)**推导**成更加简单的方式。\n\n#### 并行代码\n\n纯函数无副作用,同时调用两个函数或同个函数被同时调用两次都不会抢占外部公共资源的情况。\n\n### 总结\n\n- 程序设计中的大部分 Bug 都是有函数副作用引入的,实际开发中必须鼓励纯函数的编写。\n- 在函数式编程范畴中,欲想以函数为基础生成新的函数,那纯函数是这些新函数的基石。\n- 多尝试使用`memoize`技术对递归函数进行性能优化。\n","slug":"pure-functions-in-fp","published":1,"updated":"2023-11-07T16:01:39.154Z","_id":"clooia2lz0005ln3yesvif8os","photos":[],"link":"","content":"<p>数学上的函数指的是两个集合间的一种特殊的映射关系。这个特殊体现在什么地方呢?</p>\n<p>我们将集合<code>A</code>的元素称呼为输入值,集合<code>B</code>的元素称呼为输出值,且集合<code>A</code>、<code>B</code>存在这样的映射关系:<strong>每个输入值只会映射一个输出值,不同的输入值可以映射相同的输出值,不会出现同一个输入值映射不同的输出值</strong>。</p>\n<p>比如,下图集合<code>A</code>和集合<code>B</code>的映射关系即符合数学函数的定义。</p>\n<figure>\n <img style=\"width: auto;\" src=\"//solome.js.org/static/pure-functions-in-fp/set_map.svg\" alt=\"containing block\" />\n <figcaption>fn:除以5的余数</figcaption>\n</figure>\n\n<blockquote>\n<p>在函数式编程语言中,满足这种数学意义上的函数即为纯函数(Pure Function):相同的输入(参数),永远得到的是相同的输出(返回值),并且没有任何可观察的"副作用"。<br>自然,与纯函数的概念相反的函数(即相同参数的函数在不同环境或时机调用得到的返回值不一致)叫做非纯函数(Impure Function)。</p>\n</blockquote>\n<h3 id=\"关于函数副作用-side-effect\"><a href=\"#关于函数副作用-side-effect\" class=\"headerlink\" title=\"关于函数副作用(side-effect)\"></a>关于<strong>函数副作用</strong>(side-effect)</h3><p><strong>函数副作用</strong>指当调用函数时,在计算返回值数值的过程中,对主调用函数产生附加的影响。</p>\n<h4 id=\"更高作用域的变量-quot-悄悄-quot-发生变更\"><a href=\"#更高作用域的变量-quot-悄悄-quot-发生变更\" class=\"headerlink\" title=\"更高作用域的变量"悄悄"发生变更\"></a>更高作用域的变量"悄悄"发生变更</h4><figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">let</span> glob = <span class=\"hljs-number\">1</span><br><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">x</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> ++glob + x<br>}<br><span class=\"hljs-built_in\">console</span>.log(foo(<span class=\"hljs-number\">1</span>)) <span class=\"hljs-comment\">// => 3</span><br></code></pre></td></tr></table></figure>\n\n<p>变量<code>glob</code>的值随着<code>foo()</code>的调用发生变化,表现得很不明显。</p>\n<h4 id=\"quot-隐晦-quot-地修改了引用参数\"><a href=\"#quot-隐晦-quot-地修改了引用参数\" class=\"headerlink\" title=\""隐晦"地修改了引用参数\"></a>"隐晦"地修改了引用参数</h4><figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">let</span> glob = <span class=\"hljs-number\">1</span><br><span class=\"hljs-keyword\">const</span> obj = { glob }<br><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">x</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> ++x.glob<br>}<br>foo(obj)<br><span class=\"hljs-built_in\">console</span>.log(glob) <span class=\"hljs-comment\">// => 2</span><br></code></pre></td></tr></table></figure>\n\n<p>虽然对象<code>obj</code>定义为<code>const</code>,但是修改了间接引用的变量<code>glob</code>;这种场景引发的 bug 其实是很难捕获的(尤其是具备指针概念的 C/C++语言)。</p>\n<p>函数副作用确实是滋生 Bug 的"温床",造成的问题一般都很"隐晦";有些开发场景中,我们其实也无法避免函数的副作用(典型的例子是读写数据库操作的函数)。最好的做法是,要将这些副作用限制在可控的范围内。</p>\n<h3 id=\"纯函数带来的好处\"><a href=\"#纯函数带来的好处\" class=\"headerlink\" title=\"纯函数带来的好处\"></a>纯函数带来的好处</h3><h4 id=\"函数调用结果可缓存\"><a href=\"#函数调用结果可缓存\" class=\"headerlink\" title=\"函数调用结果可缓存\"></a>函数调用结果可缓存</h4><p>相同参数得到的返回值是相同的。如果通过参数获取返回值的过程计算量过大,我们可以缓存函数调用的结果,避免相同参数为了获取返回值进行重复计算。典型的实践是对递归函数做性能优化的<code>memoize</code>技术。<br>以<code>fibonacci(n)</code>递归函数为例,传统的实现:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">fibonacci</span>(<span class=\"hljs-params\">n</span>) </span>{<br> <span class=\"hljs-keyword\">if</span> (n === <span class=\"hljs-number\">0</span> || n === <span class=\"hljs-number\">1</span>) <span class=\"hljs-keyword\">return</span> n<br> <span class=\"hljs-keyword\">return</span> fibonacci(n - <span class=\"hljs-number\">1</span>) + fibonacci(n - <span class=\"hljs-number\">2</span>)<br>}<br><br><span class=\"hljs-built_in\">console</span>.log(fibonacci(<span class=\"hljs-number\">10</span>))<br></code></pre></td></tr></table></figure>\n\n<p>计算的复杂度以参数<code>n</code>呈指数级增长:</p>\n<figure class=\"highlight haskell\"><table><tr><td class=\"code\"><pre><code class=\"hljs haskell\"><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">0</span>) = <span class=\"hljs-number\">0</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">1</span>) = <span class=\"hljs-number\">1</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">2</span>) = f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">0</span>) = <span class=\"hljs-number\">1</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">3</span>) = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) = <span class=\"hljs-number\">2</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">4</span>) = f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>)<br> = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) = <span class=\"hljs-number\">3</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">5</span>) = f(<span class=\"hljs-number\">4</span>) + f(<span class=\"hljs-number\">3</span>)<br> = f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>)<br> = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) = <span class=\"hljs-number\">5</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">6</span>) = f(<span class=\"hljs-number\">5</span>) + f(<span class=\"hljs-number\">4</span>)<br> = f(<span class=\"hljs-number\">4</span>) + f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>)<br> = f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>)<br> = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) = <span class=\"hljs-number\">8</span><br>... ...<br></code></pre></td></tr></table></figure>\n\n<p>为了获取<code>fibonacci(n)</code>的结果,我们不得不将<code>fibonacci(n-1)</code>和<code>fibonacci(n-2)</code>都得计算一遍;如果我们在调用一次<code>fibonacci(n)</code>之后,就将其缓存起来,下次再调用时就无需重新再计算。稍加改造,添加对计算结果的缓存:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> fibonacci = (<span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\"></span>) </span>{<br> <span class=\"hljs-keyword\">const</span> cache = {}<br><br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">fib</span>(<span class=\"hljs-params\">n</span>) </span>{<br> <span class=\"hljs-keyword\">if</span> (n <span class=\"hljs-keyword\">in</span> cache) <span class=\"hljs-keyword\">return</span> cache[n]<br> <span class=\"hljs-keyword\">return</span> (cache[n] = n === <span class=\"hljs-number\">0</span> || n === <span class=\"hljs-number\">1</span> ? n : fib(n - <span class=\"hljs-number\">1</span>) + fib(n - <span class=\"hljs-number\">2</span>))<br> }<br>})()<br><span class=\"hljs-built_in\">console</span>.log(fibonacci(<span class=\"hljs-number\">10</span>))<br></code></pre></td></tr></table></figure>\n\n<p>这是典型的以空间换效率的优化思路,避免了额外计算的浪费。<br>这样实现的前提就是,该递归函数是纯函数,相同参数得到的返回值一定是相同的;如果不能保证相同,我们无法做缓存。</p>\n<p>当然,我们可以实现一个<code>memoize()</code>函数来统一做缓存这样的工作。<br>JavaScript 函数式编程支持库如均提供<code>memoize()</code>函数,这里提供一种不太健壮(内存溢出)的实现方案。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">memoize</span>(<span class=\"hljs-params\">func</span>) </span>{<br> <span class=\"hljs-keyword\">const</span> memo = {}<br> <span class=\"hljs-keyword\">const</span> slice = <span class=\"hljs-built_in\">Array</span>.prototype.slice<br><br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\"></span>) </span>{<br> <span class=\"hljs-keyword\">const</span> args = slice.call(<span class=\"hljs-built_in\">arguments</span>)<br><br> <span class=\"hljs-keyword\">if</span> (args <span class=\"hljs-keyword\">in</span> memo) <span class=\"hljs-keyword\">return</span> memo[args]<br> <span class=\"hljs-keyword\">return</span> (memo[args] = func.apply(<span class=\"hljs-built_in\">this</span>, args))<br> }<br>}<br></code></pre></td></tr></table></figure>\n\n<blockquote>\n<p>这样函数调用的次数愈多效率会慢慢变得愈高。</p>\n</blockquote>\n<h4 id=\"便于移植和测试\"><a href=\"#便于移植和测试\" class=\"headerlink\" title=\"便于移植和测试\"></a>便于移植和测试</h4><p>纯函数是"自给自足"的,所有的函数依赖均由函数自身提供(或参数);因此,我们将一个函数移植到另外一个系统时,是无需考虑成本的<br>——当然,如果一个函数依赖一个全局变量,在移植该函数时必须"慎重",要将这个全局变量的逻辑一起迁移过去。</p>\n<p>相同参数得到的函数返回值是固定的,这一特性也使纯函数更易测试——你无需模拟出一些特殊的测试环境,只要明确定义好函数参数的范围即可。</p>\n<h4 id=\"引用透明(Referential-Transparent)\"><a href=\"#引用透明(Referential-Transparent)\" class=\"headerlink\" title=\"引用透明(Referential Transparent)\"></a>引用透明(Referential Transparent)</h4><blockquote>\n<p>An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions.<br>—— <a href=\"https://en.wikipedia.org/wiki/Referential_transparency\">https://en.wikipedia.org/wiki/Referential_transparency</a></p>\n</blockquote>\n<p>该如何理解呢?可以拿上文提到的<code>fibonacci()</code>函数举例,比如存在这样一个函数:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">n, fun</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> fun(n) + fun(n)<br>}<br><br>foo(<span class=\"hljs-number\">10</span>, fibonacci)<br></code></pre></td></tr></table></figure>\n\n<p>调用<code>foo(10, fibonacci)</code>会发现<code>fibonacci(10)</code>被执行了两遍。因为纯函数具备引用透明性,某些表达式被<strong>替换</strong>并不会改变函数的行为;因此,对<code>foo()</code>进行些许变动会使其性能得到质的提升。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">n, fun</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> fun(n) * <span class=\"hljs-number\">2</span><br>}<br></code></pre></td></tr></table></figure>\n\n<p>毕竟在此场景中,一次乘法运算成本远比一次<code>fibonacci(10)</code>递归运算的成本来得低。</p>\n<p>这里的由<code>fibonacci(n) + fibonacci(n) => 2*fibonacci(n)</code>转变完全跟数学概念中的<code>f(x) = x + x = 2 *x</code>函数推导一致。</p>\n<p>因为纯函数的引用透明的特性,我们完全可以将多个函数构成的复杂程序(函数)<strong>推导</strong>成更加简单的方式。</p>\n<h4 id=\"并行代码\"><a href=\"#并行代码\" class=\"headerlink\" title=\"并行代码\"></a>并行代码</h4><p>纯函数无副作用,同时调用两个函数或同个函数被同时调用两次都不会抢占外部公共资源的情况。</p>\n<h3 id=\"总结\"><a href=\"#总结\" class=\"headerlink\" title=\"总结\"></a>总结</h3><ul>\n<li>程序设计中的大部分 Bug 都是有函数副作用引入的,实际开发中必须鼓励纯函数的编写。</li>\n<li>在函数式编程范畴中,欲想以函数为基础生成新的函数,那纯函数是这些新函数的基石。</li>\n<li>多尝试使用<code>memoize</code>技术对递归函数进行性能优化。</li>\n</ul>\n","site":{"data":{}},"excerpt":"","more":"<p>数学上的函数指的是两个集合间的一种特殊的映射关系。这个特殊体现在什么地方呢?</p>\n<p>我们将集合<code>A</code>的元素称呼为输入值,集合<code>B</code>的元素称呼为输出值,且集合<code>A</code>、<code>B</code>存在这样的映射关系:<strong>每个输入值只会映射一个输出值,不同的输入值可以映射相同的输出值,不会出现同一个输入值映射不同的输出值</strong>。</p>\n<p>比如,下图集合<code>A</code>和集合<code>B</code>的映射关系即符合数学函数的定义。</p>\n<figure>\n <img style=\"width: auto;\" src=\"//solome.js.org/static/pure-functions-in-fp/set_map.svg\" alt=\"containing block\" />\n <figcaption>fn:除以5的余数</figcaption>\n</figure>\n\n<blockquote>\n<p>在函数式编程语言中,满足这种数学意义上的函数即为纯函数(Pure Function):相同的输入(参数),永远得到的是相同的输出(返回值),并且没有任何可观察的"副作用"。<br>自然,与纯函数的概念相反的函数(即相同参数的函数在不同环境或时机调用得到的返回值不一致)叫做非纯函数(Impure Function)。</p>\n</blockquote>\n<h3 id=\"关于函数副作用-side-effect\"><a href=\"#关于函数副作用-side-effect\" class=\"headerlink\" title=\"关于函数副作用(side-effect)\"></a>关于<strong>函数副作用</strong>(side-effect)</h3><p><strong>函数副作用</strong>指当调用函数时,在计算返回值数值的过程中,对主调用函数产生附加的影响。</p>\n<h4 id=\"更高作用域的变量-quot-悄悄-quot-发生变更\"><a href=\"#更高作用域的变量-quot-悄悄-quot-发生变更\" class=\"headerlink\" title=\"更高作用域的变量"悄悄"发生变更\"></a>更高作用域的变量"悄悄"发生变更</h4><figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">let</span> glob = <span class=\"hljs-number\">1</span><br><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">x</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> ++glob + x<br>}<br><span class=\"hljs-built_in\">console</span>.log(foo(<span class=\"hljs-number\">1</span>)) <span class=\"hljs-comment\">// => 3</span><br></code></pre></td></tr></table></figure>\n\n<p>变量<code>glob</code>的值随着<code>foo()</code>的调用发生变化,表现得很不明显。</p>\n<h4 id=\"quot-隐晦-quot-地修改了引用参数\"><a href=\"#quot-隐晦-quot-地修改了引用参数\" class=\"headerlink\" title=\""隐晦"地修改了引用参数\"></a>"隐晦"地修改了引用参数</h4><figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">let</span> glob = <span class=\"hljs-number\">1</span><br><span class=\"hljs-keyword\">const</span> obj = { glob }<br><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">x</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> ++x.glob<br>}<br>foo(obj)<br><span class=\"hljs-built_in\">console</span>.log(glob) <span class=\"hljs-comment\">// => 2</span><br></code></pre></td></tr></table></figure>\n\n<p>虽然对象<code>obj</code>定义为<code>const</code>,但是修改了间接引用的变量<code>glob</code>;这种场景引发的 bug 其实是很难捕获的(尤其是具备指针概念的 C/C++语言)。</p>\n<p>函数副作用确实是滋生 Bug 的"温床",造成的问题一般都很"隐晦";有些开发场景中,我们其实也无法避免函数的副作用(典型的例子是读写数据库操作的函数)。最好的做法是,要将这些副作用限制在可控的范围内。</p>\n<h3 id=\"纯函数带来的好处\"><a href=\"#纯函数带来的好处\" class=\"headerlink\" title=\"纯函数带来的好处\"></a>纯函数带来的好处</h3><h4 id=\"函数调用结果可缓存\"><a href=\"#函数调用结果可缓存\" class=\"headerlink\" title=\"函数调用结果可缓存\"></a>函数调用结果可缓存</h4><p>相同参数得到的返回值是相同的。如果通过参数获取返回值的过程计算量过大,我们可以缓存函数调用的结果,避免相同参数为了获取返回值进行重复计算。典型的实践是对递归函数做性能优化的<code>memoize</code>技术。<br>以<code>fibonacci(n)</code>递归函数为例,传统的实现:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">fibonacci</span>(<span class=\"hljs-params\">n</span>) </span>{<br> <span class=\"hljs-keyword\">if</span> (n === <span class=\"hljs-number\">0</span> || n === <span class=\"hljs-number\">1</span>) <span class=\"hljs-keyword\">return</span> n<br> <span class=\"hljs-keyword\">return</span> fibonacci(n - <span class=\"hljs-number\">1</span>) + fibonacci(n - <span class=\"hljs-number\">2</span>)<br>}<br><br><span class=\"hljs-built_in\">console</span>.log(fibonacci(<span class=\"hljs-number\">10</span>))<br></code></pre></td></tr></table></figure>\n\n<p>计算的复杂度以参数<code>n</code>呈指数级增长:</p>\n<figure class=\"highlight haskell\"><table><tr><td class=\"code\"><pre><code class=\"hljs haskell\"><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">0</span>) = <span class=\"hljs-number\">0</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">1</span>) = <span class=\"hljs-number\">1</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">2</span>) = f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">0</span>) = <span class=\"hljs-number\">1</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">3</span>) = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) = <span class=\"hljs-number\">2</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">4</span>) = f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>)<br> = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) = <span class=\"hljs-number\">3</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">5</span>) = f(<span class=\"hljs-number\">4</span>) + f(<span class=\"hljs-number\">3</span>)<br> = f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>)<br> = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) = <span class=\"hljs-number\">5</span><br><span class=\"hljs-title\">f</span>(<span class=\"hljs-number\">6</span>) = f(<span class=\"hljs-number\">5</span>) + f(<span class=\"hljs-number\">4</span>)<br> = f(<span class=\"hljs-number\">4</span>) + f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>)<br> = f(<span class=\"hljs-number\">3</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>)<br> = f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) + f(<span class=\"hljs-number\">1</span>) + f(<span class=\"hljs-number\">2</span>) = <span class=\"hljs-number\">8</span><br>... ...<br></code></pre></td></tr></table></figure>\n\n<p>为了获取<code>fibonacci(n)</code>的结果,我们不得不将<code>fibonacci(n-1)</code>和<code>fibonacci(n-2)</code>都得计算一遍;如果我们在调用一次<code>fibonacci(n)</code>之后,就将其缓存起来,下次再调用时就无需重新再计算。稍加改造,添加对计算结果的缓存:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">const</span> fibonacci = (<span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\"></span>) </span>{<br> <span class=\"hljs-keyword\">const</span> cache = {}<br><br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">fib</span>(<span class=\"hljs-params\">n</span>) </span>{<br> <span class=\"hljs-keyword\">if</span> (n <span class=\"hljs-keyword\">in</span> cache) <span class=\"hljs-keyword\">return</span> cache[n]<br> <span class=\"hljs-keyword\">return</span> (cache[n] = n === <span class=\"hljs-number\">0</span> || n === <span class=\"hljs-number\">1</span> ? n : fib(n - <span class=\"hljs-number\">1</span>) + fib(n - <span class=\"hljs-number\">2</span>))<br> }<br>})()<br><span class=\"hljs-built_in\">console</span>.log(fibonacci(<span class=\"hljs-number\">10</span>))<br></code></pre></td></tr></table></figure>\n\n<p>这是典型的以空间换效率的优化思路,避免了额外计算的浪费。<br>这样实现的前提就是,该递归函数是纯函数,相同参数得到的返回值一定是相同的;如果不能保证相同,我们无法做缓存。</p>\n<p>当然,我们可以实现一个<code>memoize()</code>函数来统一做缓存这样的工作。<br>JavaScript 函数式编程支持库如均提供<code>memoize()</code>函数,这里提供一种不太健壮(内存溢出)的实现方案。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">memoize</span>(<span class=\"hljs-params\">func</span>) </span>{<br> <span class=\"hljs-keyword\">const</span> memo = {}<br> <span class=\"hljs-keyword\">const</span> slice = <span class=\"hljs-built_in\">Array</span>.prototype.slice<br><br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\"></span>) </span>{<br> <span class=\"hljs-keyword\">const</span> args = slice.call(<span class=\"hljs-built_in\">arguments</span>)<br><br> <span class=\"hljs-keyword\">if</span> (args <span class=\"hljs-keyword\">in</span> memo) <span class=\"hljs-keyword\">return</span> memo[args]<br> <span class=\"hljs-keyword\">return</span> (memo[args] = func.apply(<span class=\"hljs-built_in\">this</span>, args))<br> }<br>}<br></code></pre></td></tr></table></figure>\n\n<blockquote>\n<p>这样函数调用的次数愈多效率会慢慢变得愈高。</p>\n</blockquote>\n<h4 id=\"便于移植和测试\"><a href=\"#便于移植和测试\" class=\"headerlink\" title=\"便于移植和测试\"></a>便于移植和测试</h4><p>纯函数是"自给自足"的,所有的函数依赖均由函数自身提供(或参数);因此,我们将一个函数移植到另外一个系统时,是无需考虑成本的<br>——当然,如果一个函数依赖一个全局变量,在移植该函数时必须"慎重",要将这个全局变量的逻辑一起迁移过去。</p>\n<p>相同参数得到的函数返回值是固定的,这一特性也使纯函数更易测试——你无需模拟出一些特殊的测试环境,只要明确定义好函数参数的范围即可。</p>\n<h4 id=\"引用透明(Referential-Transparent)\"><a href=\"#引用透明(Referential-Transparent)\" class=\"headerlink\" title=\"引用透明(Referential Transparent)\"></a>引用透明(Referential Transparent)</h4><blockquote>\n<p>An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions.<br>—— <a href=\"https://en.wikipedia.org/wiki/Referential_transparency\">https://en.wikipedia.org/wiki/Referential_transparency</a></p>\n</blockquote>\n<p>该如何理解呢?可以拿上文提到的<code>fibonacci()</code>函数举例,比如存在这样一个函数:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">n, fun</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> fun(n) + fun(n)<br>}<br><br>foo(<span class=\"hljs-number\">10</span>, fibonacci)<br></code></pre></td></tr></table></figure>\n\n<p>调用<code>foo(10, fibonacci)</code>会发现<code>fibonacci(10)</code>被执行了两遍。因为纯函数具备引用透明性,某些表达式被<strong>替换</strong>并不会改变函数的行为;因此,对<code>foo()</code>进行些许变动会使其性能得到质的提升。</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> <span class=\"hljs-title\">foo</span>(<span class=\"hljs-params\">n, fun</span>) </span>{<br> <span class=\"hljs-keyword\">return</span> fun(n) * <span class=\"hljs-number\">2</span><br>}<br></code></pre></td></tr></table></figure>\n\n<p>毕竟在此场景中,一次乘法运算成本远比一次<code>fibonacci(10)</code>递归运算的成本来得低。</p>\n<p>这里的由<code>fibonacci(n) + fibonacci(n) => 2*fibonacci(n)</code>转变完全跟数学概念中的<code>f(x) = x + x = 2 *x</code>函数推导一致。</p>\n<p>因为纯函数的引用透明的特性,我们完全可以将多个函数构成的复杂程序(函数)<strong>推导</strong>成更加简单的方式。</p>\n<h4 id=\"并行代码\"><a href=\"#并行代码\" class=\"headerlink\" title=\"并行代码\"></a>并行代码</h4><p>纯函数无副作用,同时调用两个函数或同个函数被同时调用两次都不会抢占外部公共资源的情况。</p>\n<h3 id=\"总结\"><a href=\"#总结\" class=\"headerlink\" title=\"总结\"></a>总结</h3><ul>\n<li>程序设计中的大部分 Bug 都是有函数副作用引入的,实际开发中必须鼓励纯函数的编写。</li>\n<li>在函数式编程范畴中,欲想以函数为基础生成新的函数,那纯函数是这些新函数的基石。</li>\n<li>多尝试使用<code>memoize</code>技术对递归函数进行性能优化。</li>\n</ul>\n"},{"layout":"layout/post","title":"如视 VR 看房性能优化经验总结","date":"2023-10-26T15:30:00.000Z","comments":1,"_content":"\n<style type=\"text/css\">\n@media screen and (min-width: 960px) {\n\n .article-entry .rvp-video, .article-entry .rvp-image {\n max-width: 540px;\n }\n}\n</style>\n\n## 壹、背景\n\n贝壳 VR 看房是贝壳找房如视事业部(现已独立,[如你所视科技有限公司](https://www.realsee.com/))做的一款在线 VR 3D 看房服务。通过专业的三维空间扫描设备采集房源户型三维数据,经过算法加工之后,可以通过 WebGL/Three.js 等工具将房源以1:1复刻至浏览器上,并支持720°空间自由行走和模型、全景等多种模态间的自由切换。\n\n尤其是在新冠疫情的影响下,用户可以直接在线上进行 VR 3D 看房,降低筛选、沟通成本。此外,在后续的业务迭代中又引入 VR 带看、VR 经纪人/ AI 讲房、“一键换装”看装修等新业务模式。随着业务复杂度的提升、用户使用群体的覆盖面越来越广,性能问题已经成为项目瓶颈,亟待解决。\n\n### 1. 现状分析\n\n**业务分析**\n\n如视 VR 团队是2017年开始成立的,2018年4月份贝壳找房App 首次对外发版,VR 看房属于新品牌的核心亮点。于是从2017年开始近一年的时间内从0-1搭建贝壳VR看房,团队节奏是很紧的——倒排、抢时间。\n\n2018年后半程在贝壳 VR 看房的基础上,又新增 VR 经纪人讲房和 VR 线上实时同步带看业务。\n\n2019年初,引入早期版本的 AI 讲房业务。内部项目“未来家”——即 VR 装修(渲染)技术突破,支持“一键看装修”功能,并支持与实景 VR 同屏对比。\n\n由于2019年末、2020初新冠疫情的影响,VR 线上实时同步带看业务转变为公司级别核心业务。实现 VR 带看二手房、新房、租赁等业务全场景的覆盖,并支持微信小程序(高流量)。\n\n2021年初,则重点投入 AI 讲房业务新的探索——添加算法权重,实现 AR 数字人,往更智能(基于用户画像和性能条件实现“千人千面”体验)、更具空间表达的方向发展。 \n2021年末至今(2022年7月),团队方向调整,从贝壳找房剥离并成立如你所视科技有限公司。由支撑贝壳找房VR看房转向 SaaS、PaaS 数字空间综合解决方案创业公司。\n\n**技术分析**\n\n早期为了***快***,架构上基于 jQuery +发布/订阅者模式实现的模块化开发,后期(2020年中)转向分层+基于 React 技术栈实现的动态模块化架构形式,见下图。\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_01.png\" alt=\"前端架构图\" />\n <figcaption>图一:前端架构图</figcaption>\n</figure>\n\n### 2. 优化目标\n\n优化目标很多,本文仅抽取两点(围绕内存、FPS、TTI、进VR带看耗时这四点)进行详细说明:\n\n① 性能满足更多用户诉求,贝壳VR 看房覆盖面更广,不能局限于某些高端设备——**提高用户覆盖面**。 \n② **几个关键路径体验** 亟待解决,已经阻塞业务发展——比如启动Loading耗时长、VR 带看链路上流失率高等等。\n\n## 贰、优化经验\n\n> 前期实际落地时并没有按照 [性能优化方法论](https://solome.js.org/docs/methodology/performance) 来执行(当初也没经验),实际上也因此踩了很多坑,浪费了很多时间、资源——特别是在旧架构体系上和产品策略上做的工作 ROI 极低。\n\n### 1. 指标体系\n\n#### 1.1 系统指标\n\n房源的VR 3D模型是通过WebView基于前端WebGL能力渲染出的,核心指标有两个:\n\n- 内存占用(iOS 端直接上报;线上 Android 端无法上报、黑盒,只能通过 [PerfDog](https://perfdog.qq.com/) 线下统计)。\n- 体现流畅度的 FPS 值。\n\n分析分布大致的结论如下:\n\n- **内存(高崩溃率)**:一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB内存(最低值区间700MB),但线上平均指标实际是 1.2G——而 iOS 系统崩溃阈值是1.5G左右;Android 系统差异大,无明确阈值。\n- **FPS**:前11s平均50fps以内,正常55fps以上。是合格值,但是进入 VR 7s 阶段,FPS 降至 40fps 以下,拉低平均值。\n\n#### 1.2 关键路径指标\n\n关键路径指标有很多,这里抽取两个做详细说明:\n\n- **TTI**:可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。这个过程有 Loading 过程,内部又称为 Loading 耗时长,优化前平均值在7s左右,优化后2s。\n- **点击 VR 带看入口到带看就绪耗时**:优化前21s,优化后用户发起端1s内,经纪人端2.5s。\n\n> 此外,还有跟渲染引擎相关模型渲染、模态切换等指标,由于偏三维领域,本文不展开。本文分别去两个系统指标和关键路径指标进行分析、经验介绍。\n\n### 2. 摸底分析\n\n#### 2.1 内存\n\n前文提到,一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB 内存,但线上平均指标实际是 1.2G。分析定位后发现:\n\n- **非 VR 渲染模块**:除了 VR 耗资源之外,还有地图(百度/腾讯)、多媒体(小区图集/小区视频/讲房音频等)等模块亦占用内存。\n- **RTC 功能**:除了渲染模块之外,VR 带看依赖的 RTC 功能(实时语音)也会占用 WebView 进程资源。\n- **UI 资源**:首面板逐帧动画以及其他过渡动画等。\n\n这些占用内存的模块短期内都是无法省去的,因此性能指标的瓶颈在 1.2G。而且,功能越用越多,内存占用越高,崩溃的概率越高。\n\n#### 2.2 FPS\n\n除了在7s左右 FPS 急剧下降之外,整体 FPS 处在合理值范畴。为啥 7s 左右 FPS 会明显下降呢?主要是这里有个 **用计算换降低存储空间成本** 的优化——将三角面片数据及 uv 贴图数据压缩后存储,端上使用再解压使用。\n\n#### 2.3 TTI\n\n可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。分析后,关键流程如下:\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_02.svg\" alt=\"启动 Loading 耗时关键阶段流程图\" />\n <figcaption>图二:启动 Loading 耗时关键阶段流程图</figcaption>\n</figure>\n\n从关键流程图来看,到能交互阶段(虽然是部分交互),需要大概7s时间。\n\n**Node 计算**\n\n- WHY:户型图敏感数据,不适合暴露在端上计算(比如两点间最短路径)。或无理由,就是写在 Node 层。\n- 调整:计算结果缓存,离线化支持。\n\n**浏览器端渲染**\n\n- WHY:全模块渲染,无动态加载。造成 js 臃肿(依赖的 Three.js 库本身就巨)。\n- 调整:需 **架构升级**、先分层、非首屏内容异步加载或用户触发渲染。\n\n**六张图居然要花4s去下载?**\n\n- WHY:由于 JS/CSS/图标等静态资源(前4s大概200多个 HTTP 请求)都在同个CDN域上,浏览器或 WebView 同时只能执行3-5个 HTTP 请求,无法并行请求六张全景图片。\n- 调整:多 CDN 域名 + HTTP2 多路复用支持。\n\n#### 2.4 点击 VR 带看入口到带看就绪耗时\n\n何为VR带看?VR带看是指用户和经纪人(可以多个用户、多个经纪人)打开同个VR 页面,可以实时语音并且交互画面同步,视频效果如下:\n\n<figure>\n <video class=\"rvp-video\" src=\"//solome.js.org/static/realsee-vr-performance/live_sync.7b9ea663.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n**VR 带看启动流程**\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_03.svg\" alt=\"VR 带看启动流程耗时节点流程图\" />\n <figcaption>图三:VR 带看启动流程耗时节点流程图</figcaption>\n</figure>\n\n线下分析15s耗时进入带看就绪状态,但线上真实情况却是21s左右。\n\nVR 带看类似于远程视频语音,只不过视频内容换成了 VR 画面同屏。可想而之,从触发到就绪需要21s,这是用户不可接受的,这个业务推广面临极大的困难。\n\n### 3. 策略调整\n\n#### 3.1 产品策略调整\n\n- 内存:产品经理将页面拆分为 **首屏模块** 和 **非首屏模块**,首屏模块强制渲染,非首屏模块延迟渲染或用户触发加载——旧的前端架构不支持。\n- 点击VR带看入口到带看就绪耗时:\n - 不需要新开启 WebView,直接在原有的 WebView 上执行带看流程——*旧的前端架构不支持*。\n - 就绪重新定义:不需要等 RTC 联通、三维模型渲染就绪才能进入带看;只要 WebSocket 联通就行。\n - 新产品模式:抢单模式,一个用户对应多个线上经纪人/职业顾问,谁先响应客户资源归谁。\n\n#### 3.2 技术架构升级\n\n从产品策略的调整来看,基于 jQuery +发布/订阅者模式实现的增量式模块化开发前端架构已经不满足现有的业务和性能诉求。原有的设计是典型的SPA应用,但是新的架构诉求则更像是一个平台,即架构上分层:数据层、View 层,View 层又细分 DOM 层、Canvas 层、协议层及基础插件层。数据层和 View 层组成基础的首屏内容,非首屏内容则基于这两层以动态模块的形式进行开发——**需要时挂载**(占内存),**不需要时卸载**(会延迟清部分内存)。\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_06.svg\" alt=\"前端架构分层设计\" />\n <figcaption>图四:前端架构分层设计</figcaption>\n</figure>\n\n图四是图一的简化版本,以首屏内容(产品定义)为核心,非首屏内容以动态模块“热插拔”式支持:\n\n- 数据层:基于 [MobX](https://mobx.js.org/README.html) 二次抽象,以React Context `<StoreProvider>` 形式驱动UI。\n- 协议层:类 jsBridge,实现与客户端通信,保障业务层逻辑通用——App(iOS/Android) 即jsBridge,小程序依托 WebSocket 实现。\n- DOM 层:HTML 标签二维交互。\n- Canvas 层:基于 WebGL 三维模型建模抽象——Three.js 生态及自研渲染引擎。\n- 插件层:以插件的形式进行抽象,实现二维 DOM 和三维 Canvas 混合编程。\n- 动态模块:经纪人/AI 讲房、VR 带看、地图、多媒体资源等——以主副面板等形式集成。\n\n#### 3.3 产品策略和技术架构带来的提升\n\n- 内存:浅用户(功能使用少的用户,停留时长50s内)崩溃率降低明显;深度用户崩溃率有降低,但是未发生质变。\n- FPS:无直接影响。\n- TTI-Loading 耗时:由于基于首屏渲染,渲染依赖极大减少,平均值降低至3.3s;再加上摸底分析提到的优化,最后能降到到2s左右。\n- 点击VR带看入口到带看就绪耗时:\n - 用户端1s内——得益于不需要新开启 WebView,直接动态载入 VR 带看模块即可。不强依赖 RTC,瓶颈在 WebSocket 连接速度。\n - 经纪人/置业顾问端 3.5s 内,基本跟 TTI-Loading 耗时保持一致。\n\n优化后数值基本都达到预期性能指标,但TTI-Loading耗时和内存溢出问题还是严重影响业务,可以成立专项再深度去治理。\n\n### 4. 专项治理\n\n经过前面三个阶段之后,基本能做到 ***①整体指标大盘稳定***、***②产品策略合理*** 且 ***③技术架构无缺陷*** ——能考八十分的高分水准。而专项治理则是将八十分往九十分继续提高。\n\n#### 4.1 TTI 指标:Loading 耗时长\n\n虽然已经将Loading 耗时缩减到 3.3s以内了,但是这个过程本身很“膈应”,对业务还是有影响的。更进一步地我们开始思考怎么能把这个过程给去掉,但仅仅局限在 Web 前端的角度我们很难再有所突破。\n\n本着 **渐进增强** 的原则,由于我们大部分用户是在贝壳/链家App上使用VR看房服务,我们可以重复利用客户端渲染能力。\n\n分析3.3s的瓶颈:\n\n- 1s HTTP请求至浏览器端渲染(HTML「壳子」/CSS/JS等)。\n- 2s 左右的全景图片请求(六张)。\n\n至此,我们可以基于 WebView 拦截HTTP请求,让客户端提供HTTP请求预载、代理、缓存等能力。静态资源、全景贴图等在房源详情页提前请求,到 WebView 层拦截使用,终于整个流程平均值降到2s内(高端设备已经到1s内)——已经达到一个很好的效果。\n\n都是,Loading 这个过程依旧存在。我们继续深度挖掘客户端能力:客户端浅渲染三维模型——即客户端最小程度渲染三维模型(全景效果),由于资源已经提前预载,客户端渲染速度在300ms内(视终端设备性能来定),然后等 WebView 渲染就绪后再替换成前端渲染。所要做的工作是客户端渲染和前端渲染效果对齐即可。\n\n最终,300ms的延迟肉眼近乎无法感知,无缝衔接——效果如下视频。这个加载效果也步入业内第一梯队。\n\n<figure>\n <video style=\"max-width: 240px;\" src=\"//solome.js.org/static/realsee-vr-performance/1657609527484.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n#### 4.2 内存溢出\n\n由于动态载入\\卸载的加成由于内存瓶颈造成的崩溃率已经有较明显下降。但是针对深度用户,崩溃依旧无法避免,但这部分用户又尤其重要。\n\n同样的,遵循 **渐进增强,优雅降级** 的原则,我们先系统地整理了影响内存情况的所有因素——见内存溢出影响因素鱼骨图。\n\n<figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_04.png\" alt=\"内存溢出影响因素鱼骨图\" />\n <figcaption>图五:内存溢出影响因素鱼骨图</figcaption>\n</figure>\n\n同时按照线上内存性能分布情况、算法用户画像分析和测试团队线下测试情况建立了一份数据库。基于这份数据库和算法的用户画像数据来给用户提供不同的功能——即“千人千面”的用户体验,大体逻辑如下:\n\n- 针对低端环境用户(终端设备性能弱,电池影响等):仅提供基本功能,高端功能(高分辨率、装修对比等)禁用(不会加载渲染)。\n- 针对高端环境用户(高性能设备):渲染质量高,功能丰富。\n- 针对用户画像提供功能:比如,用户对装修感兴趣,则推荐装修模块;比如,用户购买意向高,则渐进推荐 VR 带看、AI 讲房等功能\n\n至此,将原本前端性能优化工作转换成算法团队根据用户画像来推荐功能的工作。性能状况是用户画像的一部分,在性能条件容许的情况下给用户最好的体验和功能,而非之前一股脑儿全给——不管你是什么样的用户,都能得到合适的 VR 3D 看房服务体验。\n\n而前端的工作重点则开始转变解析 WebSocket 推送的指令——在首屏模块的基础上,该渲染哪些异步模块,该何时卸载哪些异步模块,卸载的同时内存的清理情况。\n\n> ***很可惜这部分并没有很务实地落地***——*可能对于家长而言,孩子考八十就足够了,不强求九十分或更高~*\n\n## 叁、表格形式-简化\n\n<figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_05.png\" alt=\"表格形式-简化\" />\n</figure>\n","source":"_posts/realsee-vr-performance.md","raw":"---\nlayout: layout/post\ntitle: '如视 VR 看房性能优化经验总结'\ndate: 2023-10-26 23:30:00 +0800\ncomments: true\ncategories: 技术总结\n---\n\n<style type=\"text/css\">\n@media screen and (min-width: 960px) {\n\n .article-entry .rvp-video, .article-entry .rvp-image {\n max-width: 540px;\n }\n}\n</style>\n\n## 壹、背景\n\n贝壳 VR 看房是贝壳找房如视事业部(现已独立,[如你所视科技有限公司](https://www.realsee.com/))做的一款在线 VR 3D 看房服务。通过专业的三维空间扫描设备采集房源户型三维数据,经过算法加工之后,可以通过 WebGL/Three.js 等工具将房源以1:1复刻至浏览器上,并支持720°空间自由行走和模型、全景等多种模态间的自由切换。\n\n尤其是在新冠疫情的影响下,用户可以直接在线上进行 VR 3D 看房,降低筛选、沟通成本。此外,在后续的业务迭代中又引入 VR 带看、VR 经纪人/ AI 讲房、“一键换装”看装修等新业务模式。随着业务复杂度的提升、用户使用群体的覆盖面越来越广,性能问题已经成为项目瓶颈,亟待解决。\n\n### 1. 现状分析\n\n**业务分析**\n\n如视 VR 团队是2017年开始成立的,2018年4月份贝壳找房App 首次对外发版,VR 看房属于新品牌的核心亮点。于是从2017年开始近一年的时间内从0-1搭建贝壳VR看房,团队节奏是很紧的——倒排、抢时间。\n\n2018年后半程在贝壳 VR 看房的基础上,又新增 VR 经纪人讲房和 VR 线上实时同步带看业务。\n\n2019年初,引入早期版本的 AI 讲房业务。内部项目“未来家”——即 VR 装修(渲染)技术突破,支持“一键看装修”功能,并支持与实景 VR 同屏对比。\n\n由于2019年末、2020初新冠疫情的影响,VR 线上实时同步带看业务转变为公司级别核心业务。实现 VR 带看二手房、新房、租赁等业务全场景的覆盖,并支持微信小程序(高流量)。\n\n2021年初,则重点投入 AI 讲房业务新的探索——添加算法权重,实现 AR 数字人,往更智能(基于用户画像和性能条件实现“千人千面”体验)、更具空间表达的方向发展。 \n2021年末至今(2022年7月),团队方向调整,从贝壳找房剥离并成立如你所视科技有限公司。由支撑贝壳找房VR看房转向 SaaS、PaaS 数字空间综合解决方案创业公司。\n\n**技术分析**\n\n早期为了***快***,架构上基于 jQuery +发布/订阅者模式实现的模块化开发,后期(2020年中)转向分层+基于 React 技术栈实现的动态模块化架构形式,见下图。\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_01.png\" alt=\"前端架构图\" />\n <figcaption>图一:前端架构图</figcaption>\n</figure>\n\n### 2. 优化目标\n\n优化目标很多,本文仅抽取两点(围绕内存、FPS、TTI、进VR带看耗时这四点)进行详细说明:\n\n① 性能满足更多用户诉求,贝壳VR 看房覆盖面更广,不能局限于某些高端设备——**提高用户覆盖面**。 \n② **几个关键路径体验** 亟待解决,已经阻塞业务发展——比如启动Loading耗时长、VR 带看链路上流失率高等等。\n\n## 贰、优化经验\n\n> 前期实际落地时并没有按照 [性能优化方法论](https://solome.js.org/docs/methodology/performance) 来执行(当初也没经验),实际上也因此踩了很多坑,浪费了很多时间、资源——特别是在旧架构体系上和产品策略上做的工作 ROI 极低。\n\n### 1. 指标体系\n\n#### 1.1 系统指标\n\n房源的VR 3D模型是通过WebView基于前端WebGL能力渲染出的,核心指标有两个:\n\n- 内存占用(iOS 端直接上报;线上 Android 端无法上报、黑盒,只能通过 [PerfDog](https://perfdog.qq.com/) 线下统计)。\n- 体现流畅度的 FPS 值。\n\n分析分布大致的结论如下:\n\n- **内存(高崩溃率)**:一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB内存(最低值区间700MB),但线上平均指标实际是 1.2G——而 iOS 系统崩溃阈值是1.5G左右;Android 系统差异大,无明确阈值。\n- **FPS**:前11s平均50fps以内,正常55fps以上。是合格值,但是进入 VR 7s 阶段,FPS 降至 40fps 以下,拉低平均值。\n\n#### 1.2 关键路径指标\n\n关键路径指标有很多,这里抽取两个做详细说明:\n\n- **TTI**:可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。这个过程有 Loading 过程,内部又称为 Loading 耗时长,优化前平均值在7s左右,优化后2s。\n- **点击 VR 带看入口到带看就绪耗时**:优化前21s,优化后用户发起端1s内,经纪人端2.5s。\n\n> 此外,还有跟渲染引擎相关模型渲染、模态切换等指标,由于偏三维领域,本文不展开。本文分别去两个系统指标和关键路径指标进行分析、经验介绍。\n\n### 2. 摸底分析\n\n#### 2.1 内存\n\n前文提到,一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB 内存,但线上平均指标实际是 1.2G。分析定位后发现:\n\n- **非 VR 渲染模块**:除了 VR 耗资源之外,还有地图(百度/腾讯)、多媒体(小区图集/小区视频/讲房音频等)等模块亦占用内存。\n- **RTC 功能**:除了渲染模块之外,VR 带看依赖的 RTC 功能(实时语音)也会占用 WebView 进程资源。\n- **UI 资源**:首面板逐帧动画以及其他过渡动画等。\n\n这些占用内存的模块短期内都是无法省去的,因此性能指标的瓶颈在 1.2G。而且,功能越用越多,内存占用越高,崩溃的概率越高。\n\n#### 2.2 FPS\n\n除了在7s左右 FPS 急剧下降之外,整体 FPS 处在合理值范畴。为啥 7s 左右 FPS 会明显下降呢?主要是这里有个 **用计算换降低存储空间成本** 的优化——将三角面片数据及 uv 贴图数据压缩后存储,端上使用再解压使用。\n\n#### 2.3 TTI\n\n可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。分析后,关键流程如下:\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_02.svg\" alt=\"启动 Loading 耗时关键阶段流程图\" />\n <figcaption>图二:启动 Loading 耗时关键阶段流程图</figcaption>\n</figure>\n\n从关键流程图来看,到能交互阶段(虽然是部分交互),需要大概7s时间。\n\n**Node 计算**\n\n- WHY:户型图敏感数据,不适合暴露在端上计算(比如两点间最短路径)。或无理由,就是写在 Node 层。\n- 调整:计算结果缓存,离线化支持。\n\n**浏览器端渲染**\n\n- WHY:全模块渲染,无动态加载。造成 js 臃肿(依赖的 Three.js 库本身就巨)。\n- 调整:需 **架构升级**、先分层、非首屏内容异步加载或用户触发渲染。\n\n**六张图居然要花4s去下载?**\n\n- WHY:由于 JS/CSS/图标等静态资源(前4s大概200多个 HTTP 请求)都在同个CDN域上,浏览器或 WebView 同时只能执行3-5个 HTTP 请求,无法并行请求六张全景图片。\n- 调整:多 CDN 域名 + HTTP2 多路复用支持。\n\n#### 2.4 点击 VR 带看入口到带看就绪耗时\n\n何为VR带看?VR带看是指用户和经纪人(可以多个用户、多个经纪人)打开同个VR 页面,可以实时语音并且交互画面同步,视频效果如下:\n\n<figure>\n <video class=\"rvp-video\" src=\"//solome.js.org/static/realsee-vr-performance/live_sync.7b9ea663.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n**VR 带看启动流程**\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_03.svg\" alt=\"VR 带看启动流程耗时节点流程图\" />\n <figcaption>图三:VR 带看启动流程耗时节点流程图</figcaption>\n</figure>\n\n线下分析15s耗时进入带看就绪状态,但线上真实情况却是21s左右。\n\nVR 带看类似于远程视频语音,只不过视频内容换成了 VR 画面同屏。可想而之,从触发到就绪需要21s,这是用户不可接受的,这个业务推广面临极大的困难。\n\n### 3. 策略调整\n\n#### 3.1 产品策略调整\n\n- 内存:产品经理将页面拆分为 **首屏模块** 和 **非首屏模块**,首屏模块强制渲染,非首屏模块延迟渲染或用户触发加载——旧的前端架构不支持。\n- 点击VR带看入口到带看就绪耗时:\n - 不需要新开启 WebView,直接在原有的 WebView 上执行带看流程——*旧的前端架构不支持*。\n - 就绪重新定义:不需要等 RTC 联通、三维模型渲染就绪才能进入带看;只要 WebSocket 联通就行。\n - 新产品模式:抢单模式,一个用户对应多个线上经纪人/职业顾问,谁先响应客户资源归谁。\n\n#### 3.2 技术架构升级\n\n从产品策略的调整来看,基于 jQuery +发布/订阅者模式实现的增量式模块化开发前端架构已经不满足现有的业务和性能诉求。原有的设计是典型的SPA应用,但是新的架构诉求则更像是一个平台,即架构上分层:数据层、View 层,View 层又细分 DOM 层、Canvas 层、协议层及基础插件层。数据层和 View 层组成基础的首屏内容,非首屏内容则基于这两层以动态模块的形式进行开发——**需要时挂载**(占内存),**不需要时卸载**(会延迟清部分内存)。\n\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_06.svg\" alt=\"前端架构分层设计\" />\n <figcaption>图四:前端架构分层设计</figcaption>\n</figure>\n\n图四是图一的简化版本,以首屏内容(产品定义)为核心,非首屏内容以动态模块“热插拔”式支持:\n\n- 数据层:基于 [MobX](https://mobx.js.org/README.html) 二次抽象,以React Context `<StoreProvider>` 形式驱动UI。\n- 协议层:类 jsBridge,实现与客户端通信,保障业务层逻辑通用——App(iOS/Android) 即jsBridge,小程序依托 WebSocket 实现。\n- DOM 层:HTML 标签二维交互。\n- Canvas 层:基于 WebGL 三维模型建模抽象——Three.js 生态及自研渲染引擎。\n- 插件层:以插件的形式进行抽象,实现二维 DOM 和三维 Canvas 混合编程。\n- 动态模块:经纪人/AI 讲房、VR 带看、地图、多媒体资源等——以主副面板等形式集成。\n\n#### 3.3 产品策略和技术架构带来的提升\n\n- 内存:浅用户(功能使用少的用户,停留时长50s内)崩溃率降低明显;深度用户崩溃率有降低,但是未发生质变。\n- FPS:无直接影响。\n- TTI-Loading 耗时:由于基于首屏渲染,渲染依赖极大减少,平均值降低至3.3s;再加上摸底分析提到的优化,最后能降到到2s左右。\n- 点击VR带看入口到带看就绪耗时:\n - 用户端1s内——得益于不需要新开启 WebView,直接动态载入 VR 带看模块即可。不强依赖 RTC,瓶颈在 WebSocket 连接速度。\n - 经纪人/置业顾问端 3.5s 内,基本跟 TTI-Loading 耗时保持一致。\n\n优化后数值基本都达到预期性能指标,但TTI-Loading耗时和内存溢出问题还是严重影响业务,可以成立专项再深度去治理。\n\n### 4. 专项治理\n\n经过前面三个阶段之后,基本能做到 ***①整体指标大盘稳定***、***②产品策略合理*** 且 ***③技术架构无缺陷*** ——能考八十分的高分水准。而专项治理则是将八十分往九十分继续提高。\n\n#### 4.1 TTI 指标:Loading 耗时长\n\n虽然已经将Loading 耗时缩减到 3.3s以内了,但是这个过程本身很“膈应”,对业务还是有影响的。更进一步地我们开始思考怎么能把这个过程给去掉,但仅仅局限在 Web 前端的角度我们很难再有所突破。\n\n本着 **渐进增强** 的原则,由于我们大部分用户是在贝壳/链家App上使用VR看房服务,我们可以重复利用客户端渲染能力。\n\n分析3.3s的瓶颈:\n\n- 1s HTTP请求至浏览器端渲染(HTML「壳子」/CSS/JS等)。\n- 2s 左右的全景图片请求(六张)。\n\n至此,我们可以基于 WebView 拦截HTTP请求,让客户端提供HTTP请求预载、代理、缓存等能力。静态资源、全景贴图等在房源详情页提前请求,到 WebView 层拦截使用,终于整个流程平均值降到2s内(高端设备已经到1s内)——已经达到一个很好的效果。\n\n都是,Loading 这个过程依旧存在。我们继续深度挖掘客户端能力:客户端浅渲染三维模型——即客户端最小程度渲染三维模型(全景效果),由于资源已经提前预载,客户端渲染速度在300ms内(视终端设备性能来定),然后等 WebView 渲染就绪后再替换成前端渲染。所要做的工作是客户端渲染和前端渲染效果对齐即可。\n\n最终,300ms的延迟肉眼近乎无法感知,无缝衔接——效果如下视频。这个加载效果也步入业内第一梯队。\n\n<figure>\n <video style=\"max-width: 240px;\" src=\"//solome.js.org/static/realsee-vr-performance/1657609527484.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n#### 4.2 内存溢出\n\n由于动态载入\\卸载的加成由于内存瓶颈造成的崩溃率已经有较明显下降。但是针对深度用户,崩溃依旧无法避免,但这部分用户又尤其重要。\n\n同样的,遵循 **渐进增强,优雅降级** 的原则,我们先系统地整理了影响内存情况的所有因素——见内存溢出影响因素鱼骨图。\n\n<figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_04.png\" alt=\"内存溢出影响因素鱼骨图\" />\n <figcaption>图五:内存溢出影响因素鱼骨图</figcaption>\n</figure>\n\n同时按照线上内存性能分布情况、算法用户画像分析和测试团队线下测试情况建立了一份数据库。基于这份数据库和算法的用户画像数据来给用户提供不同的功能——即“千人千面”的用户体验,大体逻辑如下:\n\n- 针对低端环境用户(终端设备性能弱,电池影响等):仅提供基本功能,高端功能(高分辨率、装修对比等)禁用(不会加载渲染)。\n- 针对高端环境用户(高性能设备):渲染质量高,功能丰富。\n- 针对用户画像提供功能:比如,用户对装修感兴趣,则推荐装修模块;比如,用户购买意向高,则渐进推荐 VR 带看、AI 讲房等功能\n\n至此,将原本前端性能优化工作转换成算法团队根据用户画像来推荐功能的工作。性能状况是用户画像的一部分,在性能条件容许的情况下给用户最好的体验和功能,而非之前一股脑儿全给——不管你是什么样的用户,都能得到合适的 VR 3D 看房服务体验。\n\n而前端的工作重点则开始转变解析 WebSocket 推送的指令——在首屏模块的基础上,该渲染哪些异步模块,该何时卸载哪些异步模块,卸载的同时内存的清理情况。\n\n> ***很可惜这部分并没有很务实地落地***——*可能对于家长而言,孩子考八十就足够了,不强求九十分或更高~*\n\n## 叁、表格形式-简化\n\n<figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_05.png\" alt=\"表格形式-简化\" />\n</figure>\n","slug":"realsee-vr-performance","published":1,"updated":"2023-11-07T16:01:57.845Z","_id":"clooia2m00007ln3yd0rkctvb","photos":[],"link":"","content":"<style type=\"text/css\">\n@media screen and (min-width: 960px) {\n\n .article-entry .rvp-video, .article-entry .rvp-image {\n max-width: 540px;\n }\n}\n</style>\n\n<h2 id=\"壹、背景\"><a href=\"#壹、背景\" class=\"headerlink\" title=\"壹、背景\"></a>壹、背景</h2><p>贝壳 VR 看房是贝壳找房如视事业部(现已独立,<a href=\"https://www.realsee.com/\">如你所视科技有限公司</a>)做的一款在线 VR 3D 看房服务。通过专业的三维空间扫描设备采集房源户型三维数据,经过算法加工之后,可以通过 WebGL/Three.js 等工具将房源以1:1复刻至浏览器上,并支持720°空间自由行走和模型、全景等多种模态间的自由切换。</p>\n<p>尤其是在新冠疫情的影响下,用户可以直接在线上进行 VR 3D 看房,降低筛选、沟通成本。此外,在后续的业务迭代中又引入 VR 带看、VR 经纪人/ AI 讲房、“一键换装”看装修等新业务模式。随着业务复杂度的提升、用户使用群体的覆盖面越来越广,性能问题已经成为项目瓶颈,亟待解决。</p>\n<h3 id=\"1-现状分析\"><a href=\"#1-现状分析\" class=\"headerlink\" title=\"1. 现状分析\"></a>1. 现状分析</h3><p><strong>业务分析</strong></p>\n<p>如视 VR 团队是2017年开始成立的,2018年4月份贝壳找房App 首次对外发版,VR 看房属于新品牌的核心亮点。于是从2017年开始近一年的时间内从0-1搭建贝壳VR看房,团队节奏是很紧的——倒排、抢时间。</p>\n<p>2018年后半程在贝壳 VR 看房的基础上,又新增 VR 经纪人讲房和 VR 线上实时同步带看业务。</p>\n<p>2019年初,引入早期版本的 AI 讲房业务。内部项目“未来家”——即 VR 装修(渲染)技术突破,支持“一键看装修”功能,并支持与实景 VR 同屏对比。</p>\n<p>由于2019年末、2020初新冠疫情的影响,VR 线上实时同步带看业务转变为公司级别核心业务。实现 VR 带看二手房、新房、租赁等业务全场景的覆盖,并支持微信小程序(高流量)。</p>\n<p>2021年初,则重点投入 AI 讲房业务新的探索——添加算法权重,实现 AR 数字人,往更智能(基于用户画像和性能条件实现“千人千面”体验)、更具空间表达的方向发展。<br>2021年末至今(2022年7月),团队方向调整,从贝壳找房剥离并成立如你所视科技有限公司。由支撑贝壳找房VR看房转向 SaaS、PaaS 数字空间综合解决方案创业公司。</p>\n<p><strong>技术分析</strong></p>\n<p>早期为了<em><strong>快</strong></em>,架构上基于 jQuery +发布/订阅者模式实现的模块化开发,后期(2020年中)转向分层+基于 React 技术栈实现的动态模块化架构形式,见下图。</p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_01.png\" alt=\"前端架构图\" />\n <figcaption>图一:前端架构图</figcaption>\n</figure>\n\n<h3 id=\"2-优化目标\"><a href=\"#2-优化目标\" class=\"headerlink\" title=\"2. 优化目标\"></a>2. 优化目标</h3><p>优化目标很多,本文仅抽取两点(围绕内存、FPS、TTI、进VR带看耗时这四点)进行详细说明:</p>\n<p>① 性能满足更多用户诉求,贝壳VR 看房覆盖面更广,不能局限于某些高端设备——<strong>提高用户覆盖面</strong>。<br>② <strong>几个关键路径体验</strong> 亟待解决,已经阻塞业务发展——比如启动Loading耗时长、VR 带看链路上流失率高等等。</p>\n<h2 id=\"贰、优化经验\"><a href=\"#贰、优化经验\" class=\"headerlink\" title=\"贰、优化经验\"></a>贰、优化经验</h2><blockquote>\n<p>前期实际落地时并没有按照 <a href=\"https://solome.js.org/docs/methodology/performance\">性能优化方法论</a> 来执行(当初也没经验),实际上也因此踩了很多坑,浪费了很多时间、资源——特别是在旧架构体系上和产品策略上做的工作 ROI 极低。</p>\n</blockquote>\n<h3 id=\"1-指标体系\"><a href=\"#1-指标体系\" class=\"headerlink\" title=\"1. 指标体系\"></a>1. 指标体系</h3><h4 id=\"1-1-系统指标\"><a href=\"#1-1-系统指标\" class=\"headerlink\" title=\"1.1 系统指标\"></a>1.1 系统指标</h4><p>房源的VR 3D模型是通过WebView基于前端WebGL能力渲染出的,核心指标有两个:</p>\n<ul>\n<li>内存占用(iOS 端直接上报;线上 Android 端无法上报、黑盒,只能通过 <a href=\"https://perfdog.qq.com/\">PerfDog</a> 线下统计)。</li>\n<li>体现流畅度的 FPS 值。</li>\n</ul>\n<p>分析分布大致的结论如下:</p>\n<ul>\n<li><strong>内存(高崩溃率)</strong>:一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB内存(最低值区间700MB),但线上平均指标实际是 1.2G——而 iOS 系统崩溃阈值是1.5G左右;Android 系统差异大,无明确阈值。</li>\n<li><strong>FPS</strong>:前11s平均50fps以内,正常55fps以上。是合格值,但是进入 VR 7s 阶段,FPS 降至 40fps 以下,拉低平均值。</li>\n</ul>\n<h4 id=\"1-2-关键路径指标\"><a href=\"#1-2-关键路径指标\" class=\"headerlink\" title=\"1.2 关键路径指标\"></a>1.2 关键路径指标</h4><p>关键路径指标有很多,这里抽取两个做详细说明:</p>\n<ul>\n<li><strong>TTI</strong>:可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。这个过程有 Loading 过程,内部又称为 Loading 耗时长,优化前平均值在7s左右,优化后2s。</li>\n<li><strong>点击 VR 带看入口到带看就绪耗时</strong>:优化前21s,优化后用户发起端1s内,经纪人端2.5s。</li>\n</ul>\n<blockquote>\n<p>此外,还有跟渲染引擎相关模型渲染、模态切换等指标,由于偏三维领域,本文不展开。本文分别去两个系统指标和关键路径指标进行分析、经验介绍。</p>\n</blockquote>\n<h3 id=\"2-摸底分析\"><a href=\"#2-摸底分析\" class=\"headerlink\" title=\"2. 摸底分析\"></a>2. 摸底分析</h3><h4 id=\"2-1-内存\"><a href=\"#2-1-内存\" class=\"headerlink\" title=\"2.1 内存\"></a>2.1 内存</h4><p>前文提到,一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB 内存,但线上平均指标实际是 1.2G。分析定位后发现:</p>\n<ul>\n<li><strong>非 VR 渲染模块</strong>:除了 VR 耗资源之外,还有地图(百度/腾讯)、多媒体(小区图集/小区视频/讲房音频等)等模块亦占用内存。</li>\n<li><strong>RTC 功能</strong>:除了渲染模块之外,VR 带看依赖的 RTC 功能(实时语音)也会占用 WebView 进程资源。</li>\n<li><strong>UI 资源</strong>:首面板逐帧动画以及其他过渡动画等。</li>\n</ul>\n<p>这些占用内存的模块短期内都是无法省去的,因此性能指标的瓶颈在 1.2G。而且,功能越用越多,内存占用越高,崩溃的概率越高。</p>\n<h4 id=\"2-2-FPS\"><a href=\"#2-2-FPS\" class=\"headerlink\" title=\"2.2 FPS\"></a>2.2 FPS</h4><p>除了在7s左右 FPS 急剧下降之外,整体 FPS 处在合理值范畴。为啥 7s 左右 FPS 会明显下降呢?主要是这里有个 <strong>用计算换降低存储空间成本</strong> 的优化——将三角面片数据及 uv 贴图数据压缩后存储,端上使用再解压使用。</p>\n<h4 id=\"2-3-TTI\"><a href=\"#2-3-TTI\" class=\"headerlink\" title=\"2.3 TTI\"></a>2.3 TTI</h4><p>可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。分析后,关键流程如下:</p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_02.svg\" alt=\"启动 Loading 耗时关键阶段流程图\" />\n <figcaption>图二:启动 Loading 耗时关键阶段流程图</figcaption>\n</figure>\n\n<p>从关键流程图来看,到能交互阶段(虽然是部分交互),需要大概7s时间。</p>\n<p><strong>Node 计算</strong></p>\n<ul>\n<li>WHY:户型图敏感数据,不适合暴露在端上计算(比如两点间最短路径)。或无理由,就是写在 Node 层。</li>\n<li>调整:计算结果缓存,离线化支持。</li>\n</ul>\n<p><strong>浏览器端渲染</strong></p>\n<ul>\n<li>WHY:全模块渲染,无动态加载。造成 js 臃肿(依赖的 Three.js 库本身就巨)。</li>\n<li>调整:需 <strong>架构升级</strong>、先分层、非首屏内容异步加载或用户触发渲染。</li>\n</ul>\n<p><strong>六张图居然要花4s去下载?</strong></p>\n<ul>\n<li>WHY:由于 JS/CSS/图标等静态资源(前4s大概200多个 HTTP 请求)都在同个CDN域上,浏览器或 WebView 同时只能执行3-5个 HTTP 请求,无法并行请求六张全景图片。</li>\n<li>调整:多 CDN 域名 + HTTP2 多路复用支持。</li>\n</ul>\n<h4 id=\"2-4-点击-VR-带看入口到带看就绪耗时\"><a href=\"#2-4-点击-VR-带看入口到带看就绪耗时\" class=\"headerlink\" title=\"2.4 点击 VR 带看入口到带看就绪耗时\"></a>2.4 点击 VR 带看入口到带看就绪耗时</h4><p>何为VR带看?VR带看是指用户和经纪人(可以多个用户、多个经纪人)打开同个VR 页面,可以实时语音并且交互画面同步,视频效果如下:</p>\n<figure>\n <video class=\"rvp-video\" src=\"//solome.js.org/static/realsee-vr-performance/live_sync.7b9ea663.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n<p><strong>VR 带看启动流程</strong></p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_03.svg\" alt=\"VR 带看启动流程耗时节点流程图\" />\n <figcaption>图三:VR 带看启动流程耗时节点流程图</figcaption>\n</figure>\n\n<p>线下分析15s耗时进入带看就绪状态,但线上真实情况却是21s左右。</p>\n<p>VR 带看类似于远程视频语音,只不过视频内容换成了 VR 画面同屏。可想而之,从触发到就绪需要21s,这是用户不可接受的,这个业务推广面临极大的困难。</p>\n<h3 id=\"3-策略调整\"><a href=\"#3-策略调整\" class=\"headerlink\" title=\"3. 策略调整\"></a>3. 策略调整</h3><h4 id=\"3-1-产品策略调整\"><a href=\"#3-1-产品策略调整\" class=\"headerlink\" title=\"3.1 产品策略调整\"></a>3.1 产品策略调整</h4><ul>\n<li>内存:产品经理将页面拆分为 <strong>首屏模块</strong> 和 <strong>非首屏模块</strong>,首屏模块强制渲染,非首屏模块延迟渲染或用户触发加载——旧的前端架构不支持。</li>\n<li>点击VR带看入口到带看就绪耗时:<ul>\n<li>不需要新开启 WebView,直接在原有的 WebView 上执行带看流程——<em>旧的前端架构不支持</em>。</li>\n<li>就绪重新定义:不需要等 RTC 联通、三维模型渲染就绪才能进入带看;只要 WebSocket 联通就行。</li>\n<li>新产品模式:抢单模式,一个用户对应多个线上经纪人/职业顾问,谁先响应客户资源归谁。</li>\n</ul>\n</li>\n</ul>\n<h4 id=\"3-2-技术架构升级\"><a href=\"#3-2-技术架构升级\" class=\"headerlink\" title=\"3.2 技术架构升级\"></a>3.2 技术架构升级</h4><p>从产品策略的调整来看,基于 jQuery +发布/订阅者模式实现的增量式模块化开发前端架构已经不满足现有的业务和性能诉求。原有的设计是典型的SPA应用,但是新的架构诉求则更像是一个平台,即架构上分层:数据层、View 层,View 层又细分 DOM 层、Canvas 层、协议层及基础插件层。数据层和 View 层组成基础的首屏内容,非首屏内容则基于这两层以动态模块的形式进行开发——<strong>需要时挂载</strong>(占内存),<strong>不需要时卸载</strong>(会延迟清部分内存)。</p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_06.svg\" alt=\"前端架构分层设计\" />\n <figcaption>图四:前端架构分层设计</figcaption>\n</figure>\n\n<p>图四是图一的简化版本,以首屏内容(产品定义)为核心,非首屏内容以动态模块“热插拔”式支持:</p>\n<ul>\n<li>数据层:基于 <a href=\"https://mobx.js.org/README.html\">MobX</a> 二次抽象,以React Context <code><StoreProvider></code> 形式驱动UI。</li>\n<li>协议层:类 jsBridge,实现与客户端通信,保障业务层逻辑通用——App(iOS/Android) 即jsBridge,小程序依托 WebSocket 实现。</li>\n<li>DOM 层:HTML 标签二维交互。</li>\n<li>Canvas 层:基于 WebGL 三维模型建模抽象——Three.js 生态及自研渲染引擎。</li>\n<li>插件层:以插件的形式进行抽象,实现二维 DOM 和三维 Canvas 混合编程。</li>\n<li>动态模块:经纪人/AI 讲房、VR 带看、地图、多媒体资源等——以主副面板等形式集成。</li>\n</ul>\n<h4 id=\"3-3-产品策略和技术架构带来的提升\"><a href=\"#3-3-产品策略和技术架构带来的提升\" class=\"headerlink\" title=\"3.3 产品策略和技术架构带来的提升\"></a>3.3 产品策略和技术架构带来的提升</h4><ul>\n<li>内存:浅用户(功能使用少的用户,停留时长50s内)崩溃率降低明显;深度用户崩溃率有降低,但是未发生质变。</li>\n<li>FPS:无直接影响。</li>\n<li>TTI-Loading 耗时:由于基于首屏渲染,渲染依赖极大减少,平均值降低至3.3s;再加上摸底分析提到的优化,最后能降到到2s左右。</li>\n<li>点击VR带看入口到带看就绪耗时:<ul>\n<li>用户端1s内——得益于不需要新开启 WebView,直接动态载入 VR 带看模块即可。不强依赖 RTC,瓶颈在 WebSocket 连接速度。</li>\n<li>经纪人/置业顾问端 3.5s 内,基本跟 TTI-Loading 耗时保持一致。</li>\n</ul>\n</li>\n</ul>\n<p>优化后数值基本都达到预期性能指标,但TTI-Loading耗时和内存溢出问题还是严重影响业务,可以成立专项再深度去治理。</p>\n<h3 id=\"4-专项治理\"><a href=\"#4-专项治理\" class=\"headerlink\" title=\"4. 专项治理\"></a>4. 专项治理</h3><p>经过前面三个阶段之后,基本能做到 <em><strong>①整体指标大盘稳定</strong></em>、<em><strong>②产品策略合理</strong></em> 且 <em><strong>③技术架构无缺陷</strong></em> ——能考八十分的高分水准。而专项治理则是将八十分往九十分继续提高。</p>\n<h4 id=\"4-1-TTI-指标:Loading-耗时长\"><a href=\"#4-1-TTI-指标:Loading-耗时长\" class=\"headerlink\" title=\"4.1 TTI 指标:Loading 耗时长\"></a>4.1 TTI 指标:Loading 耗时长</h4><p>虽然已经将Loading 耗时缩减到 3.3s以内了,但是这个过程本身很“膈应”,对业务还是有影响的。更进一步地我们开始思考怎么能把这个过程给去掉,但仅仅局限在 Web 前端的角度我们很难再有所突破。</p>\n<p>本着 <strong>渐进增强</strong> 的原则,由于我们大部分用户是在贝壳/链家App上使用VR看房服务,我们可以重复利用客户端渲染能力。</p>\n<p>分析3.3s的瓶颈:</p>\n<ul>\n<li>1s HTTP请求至浏览器端渲染(HTML「壳子」/CSS/JS等)。</li>\n<li>2s 左右的全景图片请求(六张)。</li>\n</ul>\n<p>至此,我们可以基于 WebView 拦截HTTP请求,让客户端提供HTTP请求预载、代理、缓存等能力。静态资源、全景贴图等在房源详情页提前请求,到 WebView 层拦截使用,终于整个流程平均值降到2s内(高端设备已经到1s内)——已经达到一个很好的效果。</p>\n<p>都是,Loading 这个过程依旧存在。我们继续深度挖掘客户端能力:客户端浅渲染三维模型——即客户端最小程度渲染三维模型(全景效果),由于资源已经提前预载,客户端渲染速度在300ms内(视终端设备性能来定),然后等 WebView 渲染就绪后再替换成前端渲染。所要做的工作是客户端渲染和前端渲染效果对齐即可。</p>\n<p>最终,300ms的延迟肉眼近乎无法感知,无缝衔接——效果如下视频。这个加载效果也步入业内第一梯队。</p>\n<figure>\n <video style=\"max-width: 240px;\" src=\"//solome.js.org/static/realsee-vr-performance/1657609527484.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n<h4 id=\"4-2-内存溢出\"><a href=\"#4-2-内存溢出\" class=\"headerlink\" title=\"4.2 内存溢出\"></a>4.2 内存溢出</h4><p>由于动态载入\\卸载的加成由于内存瓶颈造成的崩溃率已经有较明显下降。但是针对深度用户,崩溃依旧无法避免,但这部分用户又尤其重要。</p>\n<p>同样的,遵循 <strong>渐进增强,优雅降级</strong> 的原则,我们先系统地整理了影响内存情况的所有因素——见内存溢出影响因素鱼骨图。</p>\n<figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_04.png\" alt=\"内存溢出影响因素鱼骨图\" />\n <figcaption>图五:内存溢出影响因素鱼骨图</figcaption>\n</figure>\n\n<p>同时按照线上内存性能分布情况、算法用户画像分析和测试团队线下测试情况建立了一份数据库。基于这份数据库和算法的用户画像数据来给用户提供不同的功能——即“千人千面”的用户体验,大体逻辑如下:</p>\n<ul>\n<li>针对低端环境用户(终端设备性能弱,电池影响等):仅提供基本功能,高端功能(高分辨率、装修对比等)禁用(不会加载渲染)。</li>\n<li>针对高端环境用户(高性能设备):渲染质量高,功能丰富。</li>\n<li>针对用户画像提供功能:比如,用户对装修感兴趣,则推荐装修模块;比如,用户购买意向高,则渐进推荐 VR 带看、AI 讲房等功能</li>\n</ul>\n<p>至此,将原本前端性能优化工作转换成算法团队根据用户画像来推荐功能的工作。性能状况是用户画像的一部分,在性能条件容许的情况下给用户最好的体验和功能,而非之前一股脑儿全给——不管你是什么样的用户,都能得到合适的 VR 3D 看房服务体验。</p>\n<p>而前端的工作重点则开始转变解析 WebSocket 推送的指令——在首屏模块的基础上,该渲染哪些异步模块,该何时卸载哪些异步模块,卸载的同时内存的清理情况。</p>\n<blockquote>\n<p><em><strong>很可惜这部分并没有很务实地落地</strong></em>——<em>可能对于家长而言,孩子考八十就足够了,不强求九十分或更高~</em></p>\n</blockquote>\n<h2 id=\"叁、表格形式-简化\"><a href=\"#叁、表格形式-简化\" class=\"headerlink\" title=\"叁、表格形式-简化\"></a>叁、表格形式-简化</h2><figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_05.png\" alt=\"表格形式-简化\" />\n</figure>\n","site":{"data":{}},"excerpt":"","more":"<style type=\"text/css\">\n@media screen and (min-width: 960px) {\n\n .article-entry .rvp-video, .article-entry .rvp-image {\n max-width: 540px;\n }\n}\n</style>\n\n<h2 id=\"壹、背景\"><a href=\"#壹、背景\" class=\"headerlink\" title=\"壹、背景\"></a>壹、背景</h2><p>贝壳 VR 看房是贝壳找房如视事业部(现已独立,<a href=\"https://www.realsee.com/\">如你所视科技有限公司</a>)做的一款在线 VR 3D 看房服务。通过专业的三维空间扫描设备采集房源户型三维数据,经过算法加工之后,可以通过 WebGL/Three.js 等工具将房源以1:1复刻至浏览器上,并支持720°空间自由行走和模型、全景等多种模态间的自由切换。</p>\n<p>尤其是在新冠疫情的影响下,用户可以直接在线上进行 VR 3D 看房,降低筛选、沟通成本。此外,在后续的业务迭代中又引入 VR 带看、VR 经纪人/ AI 讲房、“一键换装”看装修等新业务模式。随着业务复杂度的提升、用户使用群体的覆盖面越来越广,性能问题已经成为项目瓶颈,亟待解决。</p>\n<h3 id=\"1-现状分析\"><a href=\"#1-现状分析\" class=\"headerlink\" title=\"1. 现状分析\"></a>1. 现状分析</h3><p><strong>业务分析</strong></p>\n<p>如视 VR 团队是2017年开始成立的,2018年4月份贝壳找房App 首次对外发版,VR 看房属于新品牌的核心亮点。于是从2017年开始近一年的时间内从0-1搭建贝壳VR看房,团队节奏是很紧的——倒排、抢时间。</p>\n<p>2018年后半程在贝壳 VR 看房的基础上,又新增 VR 经纪人讲房和 VR 线上实时同步带看业务。</p>\n<p>2019年初,引入早期版本的 AI 讲房业务。内部项目“未来家”——即 VR 装修(渲染)技术突破,支持“一键看装修”功能,并支持与实景 VR 同屏对比。</p>\n<p>由于2019年末、2020初新冠疫情的影响,VR 线上实时同步带看业务转变为公司级别核心业务。实现 VR 带看二手房、新房、租赁等业务全场景的覆盖,并支持微信小程序(高流量)。</p>\n<p>2021年初,则重点投入 AI 讲房业务新的探索——添加算法权重,实现 AR 数字人,往更智能(基于用户画像和性能条件实现“千人千面”体验)、更具空间表达的方向发展。<br>2021年末至今(2022年7月),团队方向调整,从贝壳找房剥离并成立如你所视科技有限公司。由支撑贝壳找房VR看房转向 SaaS、PaaS 数字空间综合解决方案创业公司。</p>\n<p><strong>技术分析</strong></p>\n<p>早期为了<em><strong>快</strong></em>,架构上基于 jQuery +发布/订阅者模式实现的模块化开发,后期(2020年中)转向分层+基于 React 技术栈实现的动态模块化架构形式,见下图。</p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_01.png\" alt=\"前端架构图\" />\n <figcaption>图一:前端架构图</figcaption>\n</figure>\n\n<h3 id=\"2-优化目标\"><a href=\"#2-优化目标\" class=\"headerlink\" title=\"2. 优化目标\"></a>2. 优化目标</h3><p>优化目标很多,本文仅抽取两点(围绕内存、FPS、TTI、进VR带看耗时这四点)进行详细说明:</p>\n<p>① 性能满足更多用户诉求,贝壳VR 看房覆盖面更广,不能局限于某些高端设备——<strong>提高用户覆盖面</strong>。<br>② <strong>几个关键路径体验</strong> 亟待解决,已经阻塞业务发展——比如启动Loading耗时长、VR 带看链路上流失率高等等。</p>\n<h2 id=\"贰、优化经验\"><a href=\"#贰、优化经验\" class=\"headerlink\" title=\"贰、优化经验\"></a>贰、优化经验</h2><blockquote>\n<p>前期实际落地时并没有按照 <a href=\"https://solome.js.org/docs/methodology/performance\">性能优化方法论</a> 来执行(当初也没经验),实际上也因此踩了很多坑,浪费了很多时间、资源——特别是在旧架构体系上和产品策略上做的工作 ROI 极低。</p>\n</blockquote>\n<h3 id=\"1-指标体系\"><a href=\"#1-指标体系\" class=\"headerlink\" title=\"1. 指标体系\"></a>1. 指标体系</h3><h4 id=\"1-1-系统指标\"><a href=\"#1-1-系统指标\" class=\"headerlink\" title=\"1.1 系统指标\"></a>1.1 系统指标</h4><p>房源的VR 3D模型是通过WebView基于前端WebGL能力渲染出的,核心指标有两个:</p>\n<ul>\n<li>内存占用(iOS 端直接上报;线上 Android 端无法上报、黑盒,只能通过 <a href=\"https://perfdog.qq.com/\">PerfDog</a> 线下统计)。</li>\n<li>体现流畅度的 FPS 值。</li>\n</ul>\n<p>分析分布大致的结论如下:</p>\n<ul>\n<li><strong>内存(高崩溃率)</strong>:一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB内存(最低值区间700MB),但线上平均指标实际是 1.2G——而 iOS 系统崩溃阈值是1.5G左右;Android 系统差异大,无明确阈值。</li>\n<li><strong>FPS</strong>:前11s平均50fps以内,正常55fps以上。是合格值,但是进入 VR 7s 阶段,FPS 降至 40fps 以下,拉低平均值。</li>\n</ul>\n<h4 id=\"1-2-关键路径指标\"><a href=\"#1-2-关键路径指标\" class=\"headerlink\" title=\"1.2 关键路径指标\"></a>1.2 关键路径指标</h4><p>关键路径指标有很多,这里抽取两个做详细说明:</p>\n<ul>\n<li><strong>TTI</strong>:可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。这个过程有 Loading 过程,内部又称为 Loading 耗时长,优化前平均值在7s左右,优化后2s。</li>\n<li><strong>点击 VR 带看入口到带看就绪耗时</strong>:优化前21s,优化后用户发起端1s内,经纪人端2.5s。</li>\n</ul>\n<blockquote>\n<p>此外,还有跟渲染引擎相关模型渲染、模态切换等指标,由于偏三维领域,本文不展开。本文分别去两个系统指标和关键路径指标进行分析、经验介绍。</p>\n</blockquote>\n<h3 id=\"2-摸底分析\"><a href=\"#2-摸底分析\" class=\"headerlink\" title=\"2. 摸底分析\"></a>2. 摸底分析</h3><h4 id=\"2-1-内存\"><a href=\"#2-1-内存\" class=\"headerlink\" title=\"2.1 内存\"></a>2.1 内存</h4><p>前文提到,一个 VR 占用内存大概300MB,正常情况两个 VR 实例大概700MB 内存,但线上平均指标实际是 1.2G。分析定位后发现:</p>\n<ul>\n<li><strong>非 VR 渲染模块</strong>:除了 VR 耗资源之外,还有地图(百度/腾讯)、多媒体(小区图集/小区视频/讲房音频等)等模块亦占用内存。</li>\n<li><strong>RTC 功能</strong>:除了渲染模块之外,VR 带看依赖的 RTC 功能(实时语音)也会占用 WebView 进程资源。</li>\n<li><strong>UI 资源</strong>:首面板逐帧动画以及其他过渡动画等。</li>\n</ul>\n<p>这些占用内存的模块短期内都是无法省去的,因此性能指标的瓶颈在 1.2G。而且,功能越用越多,内存占用越高,崩溃的概率越高。</p>\n<h4 id=\"2-2-FPS\"><a href=\"#2-2-FPS\" class=\"headerlink\" title=\"2.2 FPS\"></a>2.2 FPS</h4><p>除了在7s左右 FPS 急剧下降之外,整体 FPS 处在合理值范畴。为啥 7s 左右 FPS 会明显下降呢?主要是这里有个 <strong>用计算换降低存储空间成本</strong> 的优化——将三角面片数据及 uv 贴图数据压缩后存储,端上使用再解压使用。</p>\n<h4 id=\"2-3-TTI\"><a href=\"#2-3-TTI\" class=\"headerlink\" title=\"2.3 TTI\"></a>2.3 TTI</h4><p>可交互时间,即从房源详情页点击进入 VR 到 VR 页面渲染完成可交换的耗时。分析后,关键流程如下:</p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_02.svg\" alt=\"启动 Loading 耗时关键阶段流程图\" />\n <figcaption>图二:启动 Loading 耗时关键阶段流程图</figcaption>\n</figure>\n\n<p>从关键流程图来看,到能交互阶段(虽然是部分交互),需要大概7s时间。</p>\n<p><strong>Node 计算</strong></p>\n<ul>\n<li>WHY:户型图敏感数据,不适合暴露在端上计算(比如两点间最短路径)。或无理由,就是写在 Node 层。</li>\n<li>调整:计算结果缓存,离线化支持。</li>\n</ul>\n<p><strong>浏览器端渲染</strong></p>\n<ul>\n<li>WHY:全模块渲染,无动态加载。造成 js 臃肿(依赖的 Three.js 库本身就巨)。</li>\n<li>调整:需 <strong>架构升级</strong>、先分层、非首屏内容异步加载或用户触发渲染。</li>\n</ul>\n<p><strong>六张图居然要花4s去下载?</strong></p>\n<ul>\n<li>WHY:由于 JS/CSS/图标等静态资源(前4s大概200多个 HTTP 请求)都在同个CDN域上,浏览器或 WebView 同时只能执行3-5个 HTTP 请求,无法并行请求六张全景图片。</li>\n<li>调整:多 CDN 域名 + HTTP2 多路复用支持。</li>\n</ul>\n<h4 id=\"2-4-点击-VR-带看入口到带看就绪耗时\"><a href=\"#2-4-点击-VR-带看入口到带看就绪耗时\" class=\"headerlink\" title=\"2.4 点击 VR 带看入口到带看就绪耗时\"></a>2.4 点击 VR 带看入口到带看就绪耗时</h4><p>何为VR带看?VR带看是指用户和经纪人(可以多个用户、多个经纪人)打开同个VR 页面,可以实时语音并且交互画面同步,视频效果如下:</p>\n<figure>\n <video class=\"rvp-video\" src=\"//solome.js.org/static/realsee-vr-performance/live_sync.7b9ea663.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n<p><strong>VR 带看启动流程</strong></p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_03.svg\" alt=\"VR 带看启动流程耗时节点流程图\" />\n <figcaption>图三:VR 带看启动流程耗时节点流程图</figcaption>\n</figure>\n\n<p>线下分析15s耗时进入带看就绪状态,但线上真实情况却是21s左右。</p>\n<p>VR 带看类似于远程视频语音,只不过视频内容换成了 VR 画面同屏。可想而之,从触发到就绪需要21s,这是用户不可接受的,这个业务推广面临极大的困难。</p>\n<h3 id=\"3-策略调整\"><a href=\"#3-策略调整\" class=\"headerlink\" title=\"3. 策略调整\"></a>3. 策略调整</h3><h4 id=\"3-1-产品策略调整\"><a href=\"#3-1-产品策略调整\" class=\"headerlink\" title=\"3.1 产品策略调整\"></a>3.1 产品策略调整</h4><ul>\n<li>内存:产品经理将页面拆分为 <strong>首屏模块</strong> 和 <strong>非首屏模块</strong>,首屏模块强制渲染,非首屏模块延迟渲染或用户触发加载——旧的前端架构不支持。</li>\n<li>点击VR带看入口到带看就绪耗时:<ul>\n<li>不需要新开启 WebView,直接在原有的 WebView 上执行带看流程——<em>旧的前端架构不支持</em>。</li>\n<li>就绪重新定义:不需要等 RTC 联通、三维模型渲染就绪才能进入带看;只要 WebSocket 联通就行。</li>\n<li>新产品模式:抢单模式,一个用户对应多个线上经纪人/职业顾问,谁先响应客户资源归谁。</li>\n</ul>\n</li>\n</ul>\n<h4 id=\"3-2-技术架构升级\"><a href=\"#3-2-技术架构升级\" class=\"headerlink\" title=\"3.2 技术架构升级\"></a>3.2 技术架构升级</h4><p>从产品策略的调整来看,基于 jQuery +发布/订阅者模式实现的增量式模块化开发前端架构已经不满足现有的业务和性能诉求。原有的设计是典型的SPA应用,但是新的架构诉求则更像是一个平台,即架构上分层:数据层、View 层,View 层又细分 DOM 层、Canvas 层、协议层及基础插件层。数据层和 View 层组成基础的首屏内容,非首屏内容则基于这两层以动态模块的形式进行开发——<strong>需要时挂载</strong>(占内存),<strong>不需要时卸载</strong>(会延迟清部分内存)。</p>\n<figure>\n <img class=\"rvp-image\" src=\"//solome.js.org/static/realsee-vr-performance/image_06.svg\" alt=\"前端架构分层设计\" />\n <figcaption>图四:前端架构分层设计</figcaption>\n</figure>\n\n<p>图四是图一的简化版本,以首屏内容(产品定义)为核心,非首屏内容以动态模块“热插拔”式支持:</p>\n<ul>\n<li>数据层:基于 <a href=\"https://mobx.js.org/README.html\">MobX</a> 二次抽象,以React Context <code><StoreProvider></code> 形式驱动UI。</li>\n<li>协议层:类 jsBridge,实现与客户端通信,保障业务层逻辑通用——App(iOS/Android) 即jsBridge,小程序依托 WebSocket 实现。</li>\n<li>DOM 层:HTML 标签二维交互。</li>\n<li>Canvas 层:基于 WebGL 三维模型建模抽象——Three.js 生态及自研渲染引擎。</li>\n<li>插件层:以插件的形式进行抽象,实现二维 DOM 和三维 Canvas 混合编程。</li>\n<li>动态模块:经纪人/AI 讲房、VR 带看、地图、多媒体资源等——以主副面板等形式集成。</li>\n</ul>\n<h4 id=\"3-3-产品策略和技术架构带来的提升\"><a href=\"#3-3-产品策略和技术架构带来的提升\" class=\"headerlink\" title=\"3.3 产品策略和技术架构带来的提升\"></a>3.3 产品策略和技术架构带来的提升</h4><ul>\n<li>内存:浅用户(功能使用少的用户,停留时长50s内)崩溃率降低明显;深度用户崩溃率有降低,但是未发生质变。</li>\n<li>FPS:无直接影响。</li>\n<li>TTI-Loading 耗时:由于基于首屏渲染,渲染依赖极大减少,平均值降低至3.3s;再加上摸底分析提到的优化,最后能降到到2s左右。</li>\n<li>点击VR带看入口到带看就绪耗时:<ul>\n<li>用户端1s内——得益于不需要新开启 WebView,直接动态载入 VR 带看模块即可。不强依赖 RTC,瓶颈在 WebSocket 连接速度。</li>\n<li>经纪人/置业顾问端 3.5s 内,基本跟 TTI-Loading 耗时保持一致。</li>\n</ul>\n</li>\n</ul>\n<p>优化后数值基本都达到预期性能指标,但TTI-Loading耗时和内存溢出问题还是严重影响业务,可以成立专项再深度去治理。</p>\n<h3 id=\"4-专项治理\"><a href=\"#4-专项治理\" class=\"headerlink\" title=\"4. 专项治理\"></a>4. 专项治理</h3><p>经过前面三个阶段之后,基本能做到 <em><strong>①整体指标大盘稳定</strong></em>、<em><strong>②产品策略合理</strong></em> 且 <em><strong>③技术架构无缺陷</strong></em> ——能考八十分的高分水准。而专项治理则是将八十分往九十分继续提高。</p>\n<h4 id=\"4-1-TTI-指标:Loading-耗时长\"><a href=\"#4-1-TTI-指标:Loading-耗时长\" class=\"headerlink\" title=\"4.1 TTI 指标:Loading 耗时长\"></a>4.1 TTI 指标:Loading 耗时长</h4><p>虽然已经将Loading 耗时缩减到 3.3s以内了,但是这个过程本身很“膈应”,对业务还是有影响的。更进一步地我们开始思考怎么能把这个过程给去掉,但仅仅局限在 Web 前端的角度我们很难再有所突破。</p>\n<p>本着 <strong>渐进增强</strong> 的原则,由于我们大部分用户是在贝壳/链家App上使用VR看房服务,我们可以重复利用客户端渲染能力。</p>\n<p>分析3.3s的瓶颈:</p>\n<ul>\n<li>1s HTTP请求至浏览器端渲染(HTML「壳子」/CSS/JS等)。</li>\n<li>2s 左右的全景图片请求(六张)。</li>\n</ul>\n<p>至此,我们可以基于 WebView 拦截HTTP请求,让客户端提供HTTP请求预载、代理、缓存等能力。静态资源、全景贴图等在房源详情页提前请求,到 WebView 层拦截使用,终于整个流程平均值降到2s内(高端设备已经到1s内)——已经达到一个很好的效果。</p>\n<p>都是,Loading 这个过程依旧存在。我们继续深度挖掘客户端能力:客户端浅渲染三维模型——即客户端最小程度渲染三维模型(全景效果),由于资源已经提前预载,客户端渲染速度在300ms内(视终端设备性能来定),然后等 WebView 渲染就绪后再替换成前端渲染。所要做的工作是客户端渲染和前端渲染效果对齐即可。</p>\n<p>最终,300ms的延迟肉眼近乎无法感知,无缝衔接——效果如下视频。这个加载效果也步入业内第一梯队。</p>\n<figure>\n <video style=\"max-width: 240px;\" src=\"//solome.js.org/static/realsee-vr-performance/1657609527484.mp4\" muted=\"true\" controls alt=\"VR 同屏\" autoPlay=\"true\" />\n</figure>\n\n<h4 id=\"4-2-内存溢出\"><a href=\"#4-2-内存溢出\" class=\"headerlink\" title=\"4.2 内存溢出\"></a>4.2 内存溢出</h4><p>由于动态载入\\卸载的加成由于内存瓶颈造成的崩溃率已经有较明显下降。但是针对深度用户,崩溃依旧无法避免,但这部分用户又尤其重要。</p>\n<p>同样的,遵循 <strong>渐进增强,优雅降级</strong> 的原则,我们先系统地整理了影响内存情况的所有因素——见内存溢出影响因素鱼骨图。</p>\n<figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_04.png\" alt=\"内存溢出影响因素鱼骨图\" />\n <figcaption>图五:内存溢出影响因素鱼骨图</figcaption>\n</figure>\n\n<p>同时按照线上内存性能分布情况、算法用户画像分析和测试团队线下测试情况建立了一份数据库。基于这份数据库和算法的用户画像数据来给用户提供不同的功能——即“千人千面”的用户体验,大体逻辑如下:</p>\n<ul>\n<li>针对低端环境用户(终端设备性能弱,电池影响等):仅提供基本功能,高端功能(高分辨率、装修对比等)禁用(不会加载渲染)。</li>\n<li>针对高端环境用户(高性能设备):渲染质量高,功能丰富。</li>\n<li>针对用户画像提供功能:比如,用户对装修感兴趣,则推荐装修模块;比如,用户购买意向高,则渐进推荐 VR 带看、AI 讲房等功能</li>\n</ul>\n<p>至此,将原本前端性能优化工作转换成算法团队根据用户画像来推荐功能的工作。性能状况是用户画像的一部分,在性能条件容许的情况下给用户最好的体验和功能,而非之前一股脑儿全给——不管你是什么样的用户,都能得到合适的 VR 3D 看房服务体验。</p>\n<p>而前端的工作重点则开始转变解析 WebSocket 推送的指令——在首屏模块的基础上,该渲染哪些异步模块,该何时卸载哪些异步模块,卸载的同时内存的清理情况。</p>\n<blockquote>\n<p><em><strong>很可惜这部分并没有很务实地落地</strong></em>——<em>可能对于家长而言,孩子考八十就足够了,不强求九十分或更高~</em></p>\n</blockquote>\n<h2 id=\"叁、表格形式-简化\"><a href=\"#叁、表格形式-简化\" class=\"headerlink\" title=\"叁、表格形式-简化\"></a>叁、表格形式-简化</h2><figure>\n <img src=\"//solome.js.org/static/realsee-vr-performance/image_05.png\" alt=\"表格形式-简化\" />\n</figure>\n"},{"title":"Web 頁面上的那些圖標","date":"2015-02-10T07:26:12.000Z","comments":1,"_content":"\n一個網頁不會是由純字符組成的,需要些些訏訏的**圖標**去點綴;最早的前端的工作主要是多數人不屑的**切圖**,這與**編程**耦合太弱。\n不過話說要是絕大多數的網頁沒有那些圖標的點綴會變得多麼地慘白。\n\n在一個 HTML 結構的頁面中,使用圖標最常接觸的是標籤`<img>`和 css 屬性`background-image`。`<img>`純粹是為了顯示圖片而添加的標籤,適用於尺寸大的圖片,強調圖片的信息,不屬於頁面圖標的範疇(在 web 設計中,圖標和圖片是兩種概念:圖標在某種程度可有可惡,起到修飾點綴的效果,本身沒有什麼信息量;而圖片不同,圖片也是頁面欲展示給用戶的信息);因此,依賴`<img>`標籤實現的點綴圖標的作用的,都是不那麼合理的,因為`<img>`不是幹這種事情的,對搜索引擎亦是不友好的。\n\n下面討論下,如何給一個 web 頁面添加修飾點綴用途的圖標的方式。\n\n### 方式一:css 屬性`background-image`\n\n` background-image`主要用來設定塊級標籤的背景圖片,一般的使用形式如下:\n\n```css\n.selector {\n background-image: url('/* 要顯示的圖片網址 */');\n background-repeat: no-repeat;\n background-color: /* 背景顏色 */ ;\n}\n```\n\n這種方式不會將圖片的信息放在 HTML 結構中,而是通過 css 來維護管理的;實現方式最大的缺陷是如果一個頁面中存在好多些類似的圖標,那麼用戶客戶端的每次訪問就必須為了那些點綴增加許許多多的 HTTP 請求。\n\n當然,最好的方式是將多個小圖標軿湊成一張大圖片來避免不必要的 HTTP 請求。\n\n### 方式二:依賴`background-position`實現的 Sprite 圖\n\n將多張小圖標合併成一張大圖片,頁面元素使用時只選擇其中的一部分顯示,這樣一堆小圖標合成的大圖片一般稱作 Sprite 圖(精靈圖,雪碧圖等)。\n除了使用 css 屬性`background-image`之外,還要利用`background-position`來定位大圖中小圖標的坐標位置;通常情況下,還要指定小圖標的長寬信息,即`width`和`height`屬性。一般的使用形式如下:\n\n```css\n.selector {\n background-image: url(要顯示的圖片網址);\n background-repeat: no-repeat;\n background-position: 0 -63px;\n height: 10px;\n width: 20px;\n}\n```\n\nSprite 圖避免了多次 HTTP 請求問題,但是難點在於 Sprite 圖的手動生成是一件極其繁瑣的事情,每次更新圖標都需要重新繪製 Sprite 圖;\n小圖標在 Sprite 圖中的坐標位置在寫入 css`background-position`屬性中時也要注意。\n\nSprite 手動生成的確繁瑣,但是 Sprite 圖的自動化生成方面的技術也趨於成熟,典型的有[Spriting with Compass](http://compass-style.org/help/tutorials/spriting/)和[glue](https://github.com/jorgebastida/glue)。\n\n如果你的 css 框架是基於 Compass(sass)的話,Sprite 圖的合併並不是什麼要耗費經歷的事情;倘若不是,善用[glue](https://github.com/jorgebastida/glue)也會讓你從在折騰圖像處理軟件的非編程工作中解脫出來。\n\nCompass 在使用 Sprite 圖時直接通過`@include icon-sprite('/* 小圖標路徑 */')`即可,最後編譯成 css 文件時也會自動編譯生成對應的 Sprite 圖,你不必考慮坐標關係;即使要換個圖標,也只是更換圖標後重新編譯即可。\n\n使用[glue](https://github.com/jorgebastida/glue)則更加強大了,不僅可以生成 CSS 也可以生成 SCSS,甚至更底層地你可以生成一系列的 hash 映射數據自己動手來處理 Sprite 圖的使用邏輯;Sprite 圖中的圖標的坐標位置全部在一個 hash 表中,完全可以自由定製。\n\n最後,Sprite 圖的軿湊還有個比較費神的問題就是:那麼多的小圖標,有些頁面在用而有些頁面不用那怎麼進行軿湊 Sprite 圖呢?\n\n全部圖標都軿湊成一張大圖片?沒有必要吧,因為有些圖標在這個頁面中沒有使用到憑什麼要拼在一起呢?一般情況下 Sprite 圖的軿湊邏輯如下:\n\n- 頁面區分:軿湊的 Sprite 圖涉及的小圖只在某種類型的頁面(模塊使用)。\n- 類型區分:同種類型的圖標軿湊在一塊組成 Sprite 圖。\n\n### 方式三:圖片數字化 BASE64\n\nSprite 圖是使用圖標點綴頁面最好的解決方案之一,接近完美,但還是有一個問題需要解決:\n對圖標的重複性不友好,即不太兼容`background-repeat`屬性(通常情況下都設定為`no-repeat`);\n典型的如評分五角星,如果有五顆五角星來表示 100%,但要表示 80%時,就必須依賴`repeat`和`width:80%`。\n\n還有就是電商網站熱衷使用的`new`、`hot`等促銷提示小圖標。這些圖標是微型的,而且需出現的時機無規律;拼在 Sprite 圖中總是讓人覺得**彆扭**。\n\n此外,Sprite 圖的使用 CSS 要依賴外部的圖片,要是圖片信息直接在 CSS 文件中就好了。而 BASE64 格式的圖片可以以字符串的形式嵌入到 CSS 文件中。\n因此,復用一個 CSS 文件直接拷貝 CSS 文件即可,無需再考慮外部依賴的圖標數據。\n\nBASE64 的解碼和編碼算法也是很容易的,如 https://docs.python.org/2/library/base64.html 。通過 Compass 實現 BASE 編碼直接使用`@include inline-image(/* 圖標路徑 */)`,和前面提到的生成 Sprite 圖一樣簡單。\n\n總之,前面提到的重複的評分五角星和電商網站熱衷使用的`new`、`hot`小圖標均可以採用 BASE64 的格式。可惜的,在低端瀏覽器(IE6)是不支持這種寫法的。\n\n### 方式四:圖標也是字體 webfont\n\n前面提到的圖標都是位圖,在手機屏幕動不動就是 1080 像素的瀏覽器來說位圖在高分辨率情況下容易出現**鋸齒**。如果使用`svg`矢量圖的話,就無法進行 Sprite 化處理。\n\n`webfont`就是一種將圖標當作字體來使用(在某種程度上也可以理解成矢量圖標的 Sprite 化);將一系列的矢量圖標轉換成矢量字體集文件(如`woff`格式)和正常字體一樣使用。\n\n不過目前讓人頭疼的地方是不是所有瀏覽器都支持`webfont`,即使支持了還只能使用純色扁平的圖標,而且瀏覽器對字體的過渡優化偶爾也會造成圖標的顯示效果失真。\n\n如果一個網站的設計風格是純色調,扁平化,那麼大氛圍的使用`webfont`是個很好的選擇。\n\n### 方式五:css3 自己畫圖標\n\nCSS3 上有許多讓人欣喜的特性,比如`transorm`和`tranition`這連個變換和過渡的屬性值,在設計頁面元素背景圖時特別有效;再撮合些 CSS 動畫效果會得到通過圖片無法得到的交互效果。\n\n不過這樣的功能目前也只僅僅侷限與頁面元素的背景圖而已。\n\n另外一種情況是使用`border`屬性值的處理以很`hack`的方式繪製一些集合圖形。\n如三角形<i class=\"fa fa-caret-up fa-fw\"></i>的繪製,一般情況下兼容性最強大的 CSS 源碼如下:\n\n```css\n.triangle {\n position: absolute;\n top: 11px;\n right: 7px; /* 絕對定位 */\n width: 0;\n height: 0;\n font-size: 0;\n border: 4px dashed transparent;\n border-top: 4px solid #2bb8aa;\n *display: none;\n}\n```\n\n當然,總是有人喜歡使用 CSS 來繪製那些原本使用圖片展示的圖標;個人覺得這是耗費精力沒有必要的工作。為什麼要把那麼簡單的工作複雜化呢?CSS 畢竟是用來點綴元素的,而非用來繪圖的。\n\n最後,大部分網站圖標的使用都是上面提到的五種方式相結合進行使用的。\n\n電商網站上面的奇怪`三角形`:\n\n- 实心三角形 \"▲\"\n- 脱字号[即\"^\"]\n\n這兩種圖標一般跟導航相關(如頂部導航);用戶點擊後圖標的方向會反轉(會摻雜一些反轉動畫的效果)。\n","source":"_posts/show-icon-in-web.md","raw":"---\ntitle: 'Web 頁面上的那些圖標'\ndate: 2015-02-10 15:26:12 +0800\ncomments: true\ncategories: 技术总结\n---\n\n一個網頁不會是由純字符組成的,需要些些訏訏的**圖標**去點綴;最早的前端的工作主要是多數人不屑的**切圖**,這與**編程**耦合太弱。\n不過話說要是絕大多數的網頁沒有那些圖標的點綴會變得多麼地慘白。\n\n在一個 HTML 結構的頁面中,使用圖標最常接觸的是標籤`<img>`和 css 屬性`background-image`。`<img>`純粹是為了顯示圖片而添加的標籤,適用於尺寸大的圖片,強調圖片的信息,不屬於頁面圖標的範疇(在 web 設計中,圖標和圖片是兩種概念:圖標在某種程度可有可惡,起到修飾點綴的效果,本身沒有什麼信息量;而圖片不同,圖片也是頁面欲展示給用戶的信息);因此,依賴`<img>`標籤實現的點綴圖標的作用的,都是不那麼合理的,因為`<img>`不是幹這種事情的,對搜索引擎亦是不友好的。\n\n下面討論下,如何給一個 web 頁面添加修飾點綴用途的圖標的方式。\n\n### 方式一:css 屬性`background-image`\n\n` background-image`主要用來設定塊級標籤的背景圖片,一般的使用形式如下:\n\n```css\n.selector {\n background-image: url('/* 要顯示的圖片網址 */');\n background-repeat: no-repeat;\n background-color: /* 背景顏色 */ ;\n}\n```\n\n這種方式不會將圖片的信息放在 HTML 結構中,而是通過 css 來維護管理的;實現方式最大的缺陷是如果一個頁面中存在好多些類似的圖標,那麼用戶客戶端的每次訪問就必須為了那些點綴增加許許多多的 HTTP 請求。\n\n當然,最好的方式是將多個小圖標軿湊成一張大圖片來避免不必要的 HTTP 請求。\n\n### 方式二:依賴`background-position`實現的 Sprite 圖\n\n將多張小圖標合併成一張大圖片,頁面元素使用時只選擇其中的一部分顯示,這樣一堆小圖標合成的大圖片一般稱作 Sprite 圖(精靈圖,雪碧圖等)。\n除了使用 css 屬性`background-image`之外,還要利用`background-position`來定位大圖中小圖標的坐標位置;通常情況下,還要指定小圖標的長寬信息,即`width`和`height`屬性。一般的使用形式如下:\n\n```css\n.selector {\n background-image: url(要顯示的圖片網址);\n background-repeat: no-repeat;\n background-position: 0 -63px;\n height: 10px;\n width: 20px;\n}\n```\n\nSprite 圖避免了多次 HTTP 請求問題,但是難點在於 Sprite 圖的手動生成是一件極其繁瑣的事情,每次更新圖標都需要重新繪製 Sprite 圖;\n小圖標在 Sprite 圖中的坐標位置在寫入 css`background-position`屬性中時也要注意。\n\nSprite 手動生成的確繁瑣,但是 Sprite 圖的自動化生成方面的技術也趨於成熟,典型的有[Spriting with Compass](http://compass-style.org/help/tutorials/spriting/)和[glue](https://github.com/jorgebastida/glue)。\n\n如果你的 css 框架是基於 Compass(sass)的話,Sprite 圖的合併並不是什麼要耗費經歷的事情;倘若不是,善用[glue](https://github.com/jorgebastida/glue)也會讓你從在折騰圖像處理軟件的非編程工作中解脫出來。\n\nCompass 在使用 Sprite 圖時直接通過`@include icon-sprite('/* 小圖標路徑 */')`即可,最後編譯成 css 文件時也會自動編譯生成對應的 Sprite 圖,你不必考慮坐標關係;即使要換個圖標,也只是更換圖標後重新編譯即可。\n\n使用[glue](https://github.com/jorgebastida/glue)則更加強大了,不僅可以生成 CSS 也可以生成 SCSS,甚至更底層地你可以生成一系列的 hash 映射數據自己動手來處理 Sprite 圖的使用邏輯;Sprite 圖中的圖標的坐標位置全部在一個 hash 表中,完全可以自由定製。\n\n最後,Sprite 圖的軿湊還有個比較費神的問題就是:那麼多的小圖標,有些頁面在用而有些頁面不用那怎麼進行軿湊 Sprite 圖呢?\n\n全部圖標都軿湊成一張大圖片?沒有必要吧,因為有些圖標在這個頁面中沒有使用到憑什麼要拼在一起呢?一般情況下 Sprite 圖的軿湊邏輯如下:\n\n- 頁面區分:軿湊的 Sprite 圖涉及的小圖只在某種類型的頁面(模塊使用)。\n- 類型區分:同種類型的圖標軿湊在一塊組成 Sprite 圖。\n\n### 方式三:圖片數字化 BASE64\n\nSprite 圖是使用圖標點綴頁面最好的解決方案之一,接近完美,但還是有一個問題需要解決:\n對圖標的重複性不友好,即不太兼容`background-repeat`屬性(通常情況下都設定為`no-repeat`);\n典型的如評分五角星,如果有五顆五角星來表示 100%,但要表示 80%時,就必須依賴`repeat`和`width:80%`。\n\n還有就是電商網站熱衷使用的`new`、`hot`等促銷提示小圖標。這些圖標是微型的,而且需出現的時機無規律;拼在 Sprite 圖中總是讓人覺得**彆扭**。\n\n此外,Sprite 圖的使用 CSS 要依賴外部的圖片,要是圖片信息直接在 CSS 文件中就好了。而 BASE64 格式的圖片可以以字符串的形式嵌入到 CSS 文件中。\n因此,復用一個 CSS 文件直接拷貝 CSS 文件即可,無需再考慮外部依賴的圖標數據。\n\nBASE64 的解碼和編碼算法也是很容易的,如 https://docs.python.org/2/library/base64.html 。通過 Compass 實現 BASE 編碼直接使用`@include inline-image(/* 圖標路徑 */)`,和前面提到的生成 Sprite 圖一樣簡單。\n\n總之,前面提到的重複的評分五角星和電商網站熱衷使用的`new`、`hot`小圖標均可以採用 BASE64 的格式。可惜的,在低端瀏覽器(IE6)是不支持這種寫法的。\n\n### 方式四:圖標也是字體 webfont\n\n前面提到的圖標都是位圖,在手機屏幕動不動就是 1080 像素的瀏覽器來說位圖在高分辨率情況下容易出現**鋸齒**。如果使用`svg`矢量圖的話,就無法進行 Sprite 化處理。\n\n`webfont`就是一種將圖標當作字體來使用(在某種程度上也可以理解成矢量圖標的 Sprite 化);將一系列的矢量圖標轉換成矢量字體集文件(如`woff`格式)和正常字體一樣使用。\n\n不過目前讓人頭疼的地方是不是所有瀏覽器都支持`webfont`,即使支持了還只能使用純色扁平的圖標,而且瀏覽器對字體的過渡優化偶爾也會造成圖標的顯示效果失真。\n\n如果一個網站的設計風格是純色調,扁平化,那麼大氛圍的使用`webfont`是個很好的選擇。\n\n### 方式五:css3 自己畫圖標\n\nCSS3 上有許多讓人欣喜的特性,比如`transorm`和`tranition`這連個變換和過渡的屬性值,在設計頁面元素背景圖時特別有效;再撮合些 CSS 動畫效果會得到通過圖片無法得到的交互效果。\n\n不過這樣的功能目前也只僅僅侷限與頁面元素的背景圖而已。\n\n另外一種情況是使用`border`屬性值的處理以很`hack`的方式繪製一些集合圖形。\n如三角形<i class=\"fa fa-caret-up fa-fw\"></i>的繪製,一般情況下兼容性最強大的 CSS 源碼如下:\n\n```css\n.triangle {\n position: absolute;\n top: 11px;\n right: 7px; /* 絕對定位 */\n width: 0;\n height: 0;\n font-size: 0;\n border: 4px dashed transparent;\n border-top: 4px solid #2bb8aa;\n *display: none;\n}\n```\n\n當然,總是有人喜歡使用 CSS 來繪製那些原本使用圖片展示的圖標;個人覺得這是耗費精力沒有必要的工作。為什麼要把那麼簡單的工作複雜化呢?CSS 畢竟是用來點綴元素的,而非用來繪圖的。\n\n最後,大部分網站圖標的使用都是上面提到的五種方式相結合進行使用的。\n\n電商網站上面的奇怪`三角形`:\n\n- 实心三角形 \"▲\"\n- 脱字号[即\"^\"]\n\n這兩種圖標一般跟導航相關(如頂部導航);用戶點擊後圖標的方向會反轉(會摻雜一些反轉動畫的效果)。\n","slug":"show-icon-in-web","published":1,"updated":"2022-05-16T14:02:36.329Z","layout":"post","photos":[],"link":"","_id":"clooia2m00008ln3y1zloau1n","content":"<p>一個網頁不會是由純字符組成的,需要些些訏訏的<strong>圖標</strong>去點綴;最早的前端的工作主要是多數人不屑的<strong>切圖</strong>,這與<strong>編程</strong>耦合太弱。<br>不過話說要是絕大多數的網頁沒有那些圖標的點綴會變得多麼地慘白。</p>\n<p>在一個 HTML 結構的頁面中,使用圖標最常接觸的是標籤<code><img></code>和 css 屬性<code>background-image</code>。<code><img></code>純粹是為了顯示圖片而添加的標籤,適用於尺寸大的圖片,強調圖片的信息,不屬於頁面圖標的範疇(在 web 設計中,圖標和圖片是兩種概念:圖標在某種程度可有可惡,起到修飾點綴的效果,本身沒有什麼信息量;而圖片不同,圖片也是頁面欲展示給用戶的信息);因此,依賴<code><img></code>標籤實現的點綴圖標的作用的,都是不那麼合理的,因為<code><img></code>不是幹這種事情的,對搜索引擎亦是不友好的。</p>\n<p>下面討論下,如何給一個 web 頁面添加修飾點綴用途的圖標的方式。</p>\n<h3 id=\"方式一:css-屬性background-image\"><a href=\"#方式一:css-屬性background-image\" class=\"headerlink\" title=\"方式一:css 屬性background-image\"></a>方式一:css 屬性<code>background-image</code></h3><p><code> background-image</code>主要用來設定塊級標籤的背景圖片,一般的使用形式如下:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-selector-class\">.selector</span> {<br> <span class=\"hljs-attribute\">background-image</span>: <span class=\"hljs-built_in\">url</span>(<span class=\"hljs-string\">'/* 要顯示的圖片網址 */'</span>);<br> <span class=\"hljs-attribute\">background-repeat</span>: no-repeat;<br> <span class=\"hljs-attribute\">background-color</span>: /* 背景顏色 */ ;<br>}<br></code></pre></td></tr></table></figure>\n\n<p>這種方式不會將圖片的信息放在 HTML 結構中,而是通過 css 來維護管理的;實現方式最大的缺陷是如果一個頁面中存在好多些類似的圖標,那麼用戶客戶端的每次訪問就必須為了那些點綴增加許許多多的 HTTP 請求。</p>\n<p>當然,最好的方式是將多個小圖標軿湊成一張大圖片來避免不必要的 HTTP 請求。</p>\n<h3 id=\"方式二:依賴background-position實現的-Sprite-圖\"><a href=\"#方式二:依賴background-position實現的-Sprite-圖\" class=\"headerlink\" title=\"方式二:依賴background-position實現的 Sprite 圖\"></a>方式二:依賴<code>background-position</code>實現的 Sprite 圖</h3><p>將多張小圖標合併成一張大圖片,頁面元素使用時只選擇其中的一部分顯示,這樣一堆小圖標合成的大圖片一般稱作 Sprite 圖(精靈圖,雪碧圖等)。<br>除了使用 css 屬性<code>background-image</code>之外,還要利用<code>background-position</code>來定位大圖中小圖標的坐標位置;通常情況下,還要指定小圖標的長寬信息,即<code>width</code>和<code>height</code>屬性。一般的使用形式如下:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-selector-class\">.selector</span> {<br> <span class=\"hljs-attribute\">background-image</span>: <span class=\"hljs-built_in\">url</span>(<span class=\"hljs-string\">要顯示的圖片網址</span>);<br> <span class=\"hljs-attribute\">background-repeat</span>: no-repeat;<br> <span class=\"hljs-attribute\">background-position</span>: <span class=\"hljs-number\">0</span> -<span class=\"hljs-number\">63px</span>;<br> <span class=\"hljs-attribute\">height</span>: <span class=\"hljs-number\">10px</span>;<br> <span class=\"hljs-attribute\">width</span>: <span class=\"hljs-number\">20px</span>;<br>}<br></code></pre></td></tr></table></figure>\n\n<p>Sprite 圖避免了多次 HTTP 請求問題,但是難點在於 Sprite 圖的手動生成是一件極其繁瑣的事情,每次更新圖標都需要重新繪製 Sprite 圖;<br>小圖標在 Sprite 圖中的坐標位置在寫入 css<code>background-position</code>屬性中時也要注意。</p>\n<p>Sprite 手動生成的確繁瑣,但是 Sprite 圖的自動化生成方面的技術也趨於成熟,典型的有<a href=\"http://compass-style.org/help/tutorials/spriting/\">Spriting with Compass</a>和<a href=\"https://github.com/jorgebastida/glue\">glue</a>。</p>\n<p>如果你的 css 框架是基於 Compass(sass)的話,Sprite 圖的合併並不是什麼要耗費經歷的事情;倘若不是,善用<a href=\"https://github.com/jorgebastida/glue\">glue</a>也會讓你從在折騰圖像處理軟件的非編程工作中解脫出來。</p>\n<p>Compass 在使用 Sprite 圖時直接通過<code>@include icon-sprite('/* 小圖標路徑 */')</code>即可,最後編譯成 css 文件時也會自動編譯生成對應的 Sprite 圖,你不必考慮坐標關係;即使要換個圖標,也只是更換圖標後重新編譯即可。</p>\n<p>使用<a href=\"https://github.com/jorgebastida/glue\">glue</a>則更加強大了,不僅可以生成 CSS 也可以生成 SCSS,甚至更底層地你可以生成一系列的 hash 映射數據自己動手來處理 Sprite 圖的使用邏輯;Sprite 圖中的圖標的坐標位置全部在一個 hash 表中,完全可以自由定製。</p>\n<p>最後,Sprite 圖的軿湊還有個比較費神的問題就是:那麼多的小圖標,有些頁面在用而有些頁面不用那怎麼進行軿湊 Sprite 圖呢?</p>\n<p>全部圖標都軿湊成一張大圖片?沒有必要吧,因為有些圖標在這個頁面中沒有使用到憑什麼要拼在一起呢?一般情況下 Sprite 圖的軿湊邏輯如下:</p>\n<ul>\n<li>頁面區分:軿湊的 Sprite 圖涉及的小圖只在某種類型的頁面(模塊使用)。</li>\n<li>類型區分:同種類型的圖標軿湊在一塊組成 Sprite 圖。</li>\n</ul>\n<h3 id=\"方式三:圖片數字化-BASE64\"><a href=\"#方式三:圖片數字化-BASE64\" class=\"headerlink\" title=\"方式三:圖片數字化 BASE64\"></a>方式三:圖片數字化 BASE64</h3><p>Sprite 圖是使用圖標點綴頁面最好的解決方案之一,接近完美,但還是有一個問題需要解決:<br>對圖標的重複性不友好,即不太兼容<code>background-repeat</code>屬性(通常情況下都設定為<code>no-repeat</code>);<br>典型的如評分五角星,如果有五顆五角星來表示 100%,但要表示 80%時,就必須依賴<code>repeat</code>和<code>width:80%</code>。</p>\n<p>還有就是電商網站熱衷使用的<code>new</code>、<code>hot</code>等促銷提示小圖標。這些圖標是微型的,而且需出現的時機無規律;拼在 Sprite 圖中總是讓人覺得<strong>彆扭</strong>。</p>\n<p>此外,Sprite 圖的使用 CSS 要依賴外部的圖片,要是圖片信息直接在 CSS 文件中就好了。而 BASE64 格式的圖片可以以字符串的形式嵌入到 CSS 文件中。<br>因此,復用一個 CSS 文件直接拷貝 CSS 文件即可,無需再考慮外部依賴的圖標數據。</p>\n<p>BASE64 的解碼和編碼算法也是很容易的,如 <a href=\"https://docs.python.org/2/library/base64.html\">https://docs.python.org/2/library/base64.html</a> 。通過 Compass 實現 BASE 編碼直接使用<code>@include inline-image(/* 圖標路徑 */)</code>,和前面提到的生成 Sprite 圖一樣簡單。</p>\n<p>總之,前面提到的重複的評分五角星和電商網站熱衷使用的<code>new</code>、<code>hot</code>小圖標均可以採用 BASE64 的格式。可惜的,在低端瀏覽器(IE6)是不支持這種寫法的。</p>\n<h3 id=\"方式四:圖標也是字體-webfont\"><a href=\"#方式四:圖標也是字體-webfont\" class=\"headerlink\" title=\"方式四:圖標也是字體 webfont\"></a>方式四:圖標也是字體 webfont</h3><p>前面提到的圖標都是位圖,在手機屏幕動不動就是 1080 像素的瀏覽器來說位圖在高分辨率情況下容易出現<strong>鋸齒</strong>。如果使用<code>svg</code>矢量圖的話,就無法進行 Sprite 化處理。</p>\n<p><code>webfont</code>就是一種將圖標當作字體來使用(在某種程度上也可以理解成矢量圖標的 Sprite 化);將一系列的矢量圖標轉換成矢量字體集文件(如<code>woff</code>格式)和正常字體一樣使用。</p>\n<p>不過目前讓人頭疼的地方是不是所有瀏覽器都支持<code>webfont</code>,即使支持了還只能使用純色扁平的圖標,而且瀏覽器對字體的過渡優化偶爾也會造成圖標的顯示效果失真。</p>\n<p>如果一個網站的設計風格是純色調,扁平化,那麼大氛圍的使用<code>webfont</code>是個很好的選擇。</p>\n<h3 id=\"方式五:css3-自己畫圖標\"><a href=\"#方式五:css3-自己畫圖標\" class=\"headerlink\" title=\"方式五:css3 自己畫圖標\"></a>方式五:css3 自己畫圖標</h3><p>CSS3 上有許多讓人欣喜的特性,比如<code>transorm</code>和<code>tranition</code>這連個變換和過渡的屬性值,在設計頁面元素背景圖時特別有效;再撮合些 CSS 動畫效果會得到通過圖片無法得到的交互效果。</p>\n<p>不過這樣的功能目前也只僅僅侷限與頁面元素的背景圖而已。</p>\n<p>另外一種情況是使用<code>border</code>屬性值的處理以很<code>hack</code>的方式繪製一些集合圖形。<br>如三角形<i class=\"fa fa-caret-up fa-fw\"></i>的繪製,一般情況下兼容性最強大的 CSS 源碼如下:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-selector-class\">.triangle</span> {<br> <span class=\"hljs-attribute\">position</span>: absolute;<br> <span class=\"hljs-attribute\">top</span>: <span class=\"hljs-number\">11px</span>;<br> <span class=\"hljs-attribute\">right</span>: <span class=\"hljs-number\">7px</span>; <span class=\"hljs-comment\">/* 絕對定位 */</span><br> <span class=\"hljs-attribute\">width</span>: <span class=\"hljs-number\">0</span>;<br> <span class=\"hljs-attribute\">height</span>: <span class=\"hljs-number\">0</span>;<br> <span class=\"hljs-attribute\">font-size</span>: <span class=\"hljs-number\">0</span>;<br> <span class=\"hljs-attribute\">border</span>: <span class=\"hljs-number\">4px</span> dashed transparent;<br> <span class=\"hljs-attribute\">border-top</span>: <span class=\"hljs-number\">4px</span> solid <span class=\"hljs-number\">#2bb8aa</span>;<br> *<span class=\"hljs-attribute\">display</span>: none;<br>}<br></code></pre></td></tr></table></figure>\n\n<p>當然,總是有人喜歡使用 CSS 來繪製那些原本使用圖片展示的圖標;個人覺得這是耗費精力沒有必要的工作。為什麼要把那麼簡單的工作複雜化呢?CSS 畢竟是用來點綴元素的,而非用來繪圖的。</p>\n<p>最後,大部分網站圖標的使用都是上面提到的五種方式相結合進行使用的。</p>\n<p>電商網站上面的奇怪<code>三角形</code>:</p>\n<ul>\n<li>实心三角形 "▲"</li>\n<li>脱字号[即"^"]</li>\n</ul>\n<p>這兩種圖標一般跟導航相關(如頂部導航);用戶點擊後圖標的方向會反轉(會摻雜一些反轉動畫的效果)。</p>\n","site":{"data":{}},"excerpt":"","more":"<p>一個網頁不會是由純字符組成的,需要些些訏訏的<strong>圖標</strong>去點綴;最早的前端的工作主要是多數人不屑的<strong>切圖</strong>,這與<strong>編程</strong>耦合太弱。<br>不過話說要是絕大多數的網頁沒有那些圖標的點綴會變得多麼地慘白。</p>\n<p>在一個 HTML 結構的頁面中,使用圖標最常接觸的是標籤<code><img></code>和 css 屬性<code>background-image</code>。<code><img></code>純粹是為了顯示圖片而添加的標籤,適用於尺寸大的圖片,強調圖片的信息,不屬於頁面圖標的範疇(在 web 設計中,圖標和圖片是兩種概念:圖標在某種程度可有可惡,起到修飾點綴的效果,本身沒有什麼信息量;而圖片不同,圖片也是頁面欲展示給用戶的信息);因此,依賴<code><img></code>標籤實現的點綴圖標的作用的,都是不那麼合理的,因為<code><img></code>不是幹這種事情的,對搜索引擎亦是不友好的。</p>\n<p>下面討論下,如何給一個 web 頁面添加修飾點綴用途的圖標的方式。</p>\n<h3 id=\"方式一:css-屬性background-image\"><a href=\"#方式一:css-屬性background-image\" class=\"headerlink\" title=\"方式一:css 屬性background-image\"></a>方式一:css 屬性<code>background-image</code></h3><p><code> background-image</code>主要用來設定塊級標籤的背景圖片,一般的使用形式如下:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-selector-class\">.selector</span> {<br> <span class=\"hljs-attribute\">background-image</span>: <span class=\"hljs-built_in\">url</span>(<span class=\"hljs-string\">'/* 要顯示的圖片網址 */'</span>);<br> <span class=\"hljs-attribute\">background-repeat</span>: no-repeat;<br> <span class=\"hljs-attribute\">background-color</span>: /* 背景顏色 */ ;<br>}<br></code></pre></td></tr></table></figure>\n\n<p>這種方式不會將圖片的信息放在 HTML 結構中,而是通過 css 來維護管理的;實現方式最大的缺陷是如果一個頁面中存在好多些類似的圖標,那麼用戶客戶端的每次訪問就必須為了那些點綴增加許許多多的 HTTP 請求。</p>\n<p>當然,最好的方式是將多個小圖標軿湊成一張大圖片來避免不必要的 HTTP 請求。</p>\n<h3 id=\"方式二:依賴background-position實現的-Sprite-圖\"><a href=\"#方式二:依賴background-position實現的-Sprite-圖\" class=\"headerlink\" title=\"方式二:依賴background-position實現的 Sprite 圖\"></a>方式二:依賴<code>background-position</code>實現的 Sprite 圖</h3><p>將多張小圖標合併成一張大圖片,頁面元素使用時只選擇其中的一部分顯示,這樣一堆小圖標合成的大圖片一般稱作 Sprite 圖(精靈圖,雪碧圖等)。<br>除了使用 css 屬性<code>background-image</code>之外,還要利用<code>background-position</code>來定位大圖中小圖標的坐標位置;通常情況下,還要指定小圖標的長寬信息,即<code>width</code>和<code>height</code>屬性。一般的使用形式如下:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-selector-class\">.selector</span> {<br> <span class=\"hljs-attribute\">background-image</span>: <span class=\"hljs-built_in\">url</span>(<span class=\"hljs-string\">要顯示的圖片網址</span>);<br> <span class=\"hljs-attribute\">background-repeat</span>: no-repeat;<br> <span class=\"hljs-attribute\">background-position</span>: <span class=\"hljs-number\">0</span> -<span class=\"hljs-number\">63px</span>;<br> <span class=\"hljs-attribute\">height</span>: <span class=\"hljs-number\">10px</span>;<br> <span class=\"hljs-attribute\">width</span>: <span class=\"hljs-number\">20px</span>;<br>}<br></code></pre></td></tr></table></figure>\n\n<p>Sprite 圖避免了多次 HTTP 請求問題,但是難點在於 Sprite 圖的手動生成是一件極其繁瑣的事情,每次更新圖標都需要重新繪製 Sprite 圖;<br>小圖標在 Sprite 圖中的坐標位置在寫入 css<code>background-position</code>屬性中時也要注意。</p>\n<p>Sprite 手動生成的確繁瑣,但是 Sprite 圖的自動化生成方面的技術也趨於成熟,典型的有<a href=\"http://compass-style.org/help/tutorials/spriting/\">Spriting with Compass</a>和<a href=\"https://github.com/jorgebastida/glue\">glue</a>。</p>\n<p>如果你的 css 框架是基於 Compass(sass)的話,Sprite 圖的合併並不是什麼要耗費經歷的事情;倘若不是,善用<a href=\"https://github.com/jorgebastida/glue\">glue</a>也會讓你從在折騰圖像處理軟件的非編程工作中解脫出來。</p>\n<p>Compass 在使用 Sprite 圖時直接通過<code>@include icon-sprite('/* 小圖標路徑 */')</code>即可,最後編譯成 css 文件時也會自動編譯生成對應的 Sprite 圖,你不必考慮坐標關係;即使要換個圖標,也只是更換圖標後重新編譯即可。</p>\n<p>使用<a href=\"https://github.com/jorgebastida/glue\">glue</a>則更加強大了,不僅可以生成 CSS 也可以生成 SCSS,甚至更底層地你可以生成一系列的 hash 映射數據自己動手來處理 Sprite 圖的使用邏輯;Sprite 圖中的圖標的坐標位置全部在一個 hash 表中,完全可以自由定製。</p>\n<p>最後,Sprite 圖的軿湊還有個比較費神的問題就是:那麼多的小圖標,有些頁面在用而有些頁面不用那怎麼進行軿湊 Sprite 圖呢?</p>\n<p>全部圖標都軿湊成一張大圖片?沒有必要吧,因為有些圖標在這個頁面中沒有使用到憑什麼要拼在一起呢?一般情況下 Sprite 圖的軿湊邏輯如下:</p>\n<ul>\n<li>頁面區分:軿湊的 Sprite 圖涉及的小圖只在某種類型的頁面(模塊使用)。</li>\n<li>類型區分:同種類型的圖標軿湊在一塊組成 Sprite 圖。</li>\n</ul>\n<h3 id=\"方式三:圖片數字化-BASE64\"><a href=\"#方式三:圖片數字化-BASE64\" class=\"headerlink\" title=\"方式三:圖片數字化 BASE64\"></a>方式三:圖片數字化 BASE64</h3><p>Sprite 圖是使用圖標點綴頁面最好的解決方案之一,接近完美,但還是有一個問題需要解決:<br>對圖標的重複性不友好,即不太兼容<code>background-repeat</code>屬性(通常情況下都設定為<code>no-repeat</code>);<br>典型的如評分五角星,如果有五顆五角星來表示 100%,但要表示 80%時,就必須依賴<code>repeat</code>和<code>width:80%</code>。</p>\n<p>還有就是電商網站熱衷使用的<code>new</code>、<code>hot</code>等促銷提示小圖標。這些圖標是微型的,而且需出現的時機無規律;拼在 Sprite 圖中總是讓人覺得<strong>彆扭</strong>。</p>\n<p>此外,Sprite 圖的使用 CSS 要依賴外部的圖片,要是圖片信息直接在 CSS 文件中就好了。而 BASE64 格式的圖片可以以字符串的形式嵌入到 CSS 文件中。<br>因此,復用一個 CSS 文件直接拷貝 CSS 文件即可,無需再考慮外部依賴的圖標數據。</p>\n<p>BASE64 的解碼和編碼算法也是很容易的,如 <a href=\"https://docs.python.org/2/library/base64.html\">https://docs.python.org/2/library/base64.html</a> 。通過 Compass 實現 BASE 編碼直接使用<code>@include inline-image(/* 圖標路徑 */)</code>,和前面提到的生成 Sprite 圖一樣簡單。</p>\n<p>總之,前面提到的重複的評分五角星和電商網站熱衷使用的<code>new</code>、<code>hot</code>小圖標均可以採用 BASE64 的格式。可惜的,在低端瀏覽器(IE6)是不支持這種寫法的。</p>\n<h3 id=\"方式四:圖標也是字體-webfont\"><a href=\"#方式四:圖標也是字體-webfont\" class=\"headerlink\" title=\"方式四:圖標也是字體 webfont\"></a>方式四:圖標也是字體 webfont</h3><p>前面提到的圖標都是位圖,在手機屏幕動不動就是 1080 像素的瀏覽器來說位圖在高分辨率情況下容易出現<strong>鋸齒</strong>。如果使用<code>svg</code>矢量圖的話,就無法進行 Sprite 化處理。</p>\n<p><code>webfont</code>就是一種將圖標當作字體來使用(在某種程度上也可以理解成矢量圖標的 Sprite 化);將一系列的矢量圖標轉換成矢量字體集文件(如<code>woff</code>格式)和正常字體一樣使用。</p>\n<p>不過目前讓人頭疼的地方是不是所有瀏覽器都支持<code>webfont</code>,即使支持了還只能使用純色扁平的圖標,而且瀏覽器對字體的過渡優化偶爾也會造成圖標的顯示效果失真。</p>\n<p>如果一個網站的設計風格是純色調,扁平化,那麼大氛圍的使用<code>webfont</code>是個很好的選擇。</p>\n<h3 id=\"方式五:css3-自己畫圖標\"><a href=\"#方式五:css3-自己畫圖標\" class=\"headerlink\" title=\"方式五:css3 自己畫圖標\"></a>方式五:css3 自己畫圖標</h3><p>CSS3 上有許多讓人欣喜的特性,比如<code>transorm</code>和<code>tranition</code>這連個變換和過渡的屬性值,在設計頁面元素背景圖時特別有效;再撮合些 CSS 動畫效果會得到通過圖片無法得到的交互效果。</p>\n<p>不過這樣的功能目前也只僅僅侷限與頁面元素的背景圖而已。</p>\n<p>另外一種情況是使用<code>border</code>屬性值的處理以很<code>hack</code>的方式繪製一些集合圖形。<br>如三角形<i class=\"fa fa-caret-up fa-fw\"></i>的繪製,一般情況下兼容性最強大的 CSS 源碼如下:</p>\n<figure class=\"highlight css\"><table><tr><td class=\"code\"><pre><code class=\"hljs css\"><span class=\"hljs-selector-class\">.triangle</span> {<br> <span class=\"hljs-attribute\">position</span>: absolute;<br> <span class=\"hljs-attribute\">top</span>: <span class=\"hljs-number\">11px</span>;<br> <span class=\"hljs-attribute\">right</span>: <span class=\"hljs-number\">7px</span>; <span class=\"hljs-comment\">/* 絕對定位 */</span><br> <span class=\"hljs-attribute\">width</span>: <span class=\"hljs-number\">0</span>;<br> <span class=\"hljs-attribute\">height</span>: <span class=\"hljs-number\">0</span>;<br> <span class=\"hljs-attribute\">font-size</span>: <span class=\"hljs-number\">0</span>;<br> <span class=\"hljs-attribute\">border</span>: <span class=\"hljs-number\">4px</span> dashed transparent;<br> <span class=\"hljs-attribute\">border-top</span>: <span class=\"hljs-number\">4px</span> solid <span class=\"hljs-number\">#2bb8aa</span>;<br> *<span class=\"hljs-attribute\">display</span>: none;<br>}<br></code></pre></td></tr></table></figure>\n\n<p>當然,總是有人喜歡使用 CSS 來繪製那些原本使用圖片展示的圖標;個人覺得這是耗費精力沒有必要的工作。為什麼要把那麼簡單的工作複雜化呢?CSS 畢竟是用來點綴元素的,而非用來繪圖的。</p>\n<p>最後,大部分網站圖標的使用都是上面提到的五種方式相結合進行使用的。</p>\n<p>電商網站上面的奇怪<code>三角形</code>:</p>\n<ul>\n<li>实心三角形 "▲"</li>\n<li>脱字号[即"^"]</li>\n</ul>\n<p>這兩種圖標一般跟導航相關(如頂部導航);用戶點擊後圖標的方向會反轉(會摻雜一些反轉動畫的效果)。</p>\n"},{"title":"前端工程化开发方案 app-proto","date":"2017-01-05T12:00:00.000Z","comments":1,"_content":"\n> 本文是针对去年(2016 年 10 月)美团点评技术沙龙第 13 期分享的[《前端工程化开发方案 app-proto 介绍》](https://www.slideshare.net/meituan/13appproto)整理而来的技术博客。\n> 年初的时候又针对 Koa2.0 做了些许语法适配和完整的 SSR 支持([app-proto-2.0](https://solome.js.org/slides/app-proto-2.0/))。\n\n什么是前端工程化?根据具体的业务特点,将前端的开发流程、技术、工具、经验等规范化、标准化就是前端工程化。\n它的目的是让前端开发能够\"自成体系\",最大程度地提高前端工程师的开发效率,降低技术选型、前后端联调等带来的协调沟通成本。\n\n美团点评厦门智能住宿前端研发团队通过多个前端项目开发的探索和实践,基于\"约定优于配置\"([Convention Over Configuration](https://en.wikipedia.org/wiki/Convention_over_configuration))的原则制定了一套前端工程化开发方案 app-proto。本文将简要介绍其中的一些设计细节和约定。\n\n#### 面临的业务特点\n\n智能住宿前端团队承担的前端业务主要面向 B 端项目,用户主要是商家、销售、运营、产品经理以及研发人员。\n\n诸如工单管理、信息管理、门锁运营、PMS([Property management system](https://en.wikipedia.org/wiki/Property_management_system))、CRM([Customer relationship management](https://en.wikipedia.org/wiki/Customer_relationship_management))及 AMS([Asset management system](https://en.wikipedia.org/wiki/Asset_management))等项目都是单页面工具类应用,特点是功能交互繁多、复杂表单,非展示类、无 SEO([Search engine optimization](https://en.wikipedia.org/wiki/Search_engine_optimization))需求。\n\n如果这些项目脱离浏览器这个\"外壳\",与传统的原生桌面 GUI 软件无异。换言之,这些项目就是一种运行于浏览器的工具软件。\n\n> 实际上,部分项目我们也确实利用 CEF([Chromium Embedded Framework](https://bitbucket.org/chromiumembedded/cef))等技术给其套个\"外壳\",当作传统的桌面 GUI 应用提供给用户使用。\n\n同时,部分服务需要从智能门锁、控制盒 Wifi 等硬件设备收录状态数据,限于硬件环境测试的不稳定性,后端的开发测试周期远比前端开发周期长。大部分场景下,前后端需并行开发,后端工程师并不能在第一时间兼顾到前端所需的 API 接口等服务,给前端开发造成没有必要的\"等待期\",影响开发进度。\n\n此外,项目多、敏捷需求多、开发周期短以及面向多后端服务(多个后端团队)等也是我们前端研发团队面临的挑战。\n\n#### 一些前端经验总结\n\n针对多个项目的开发实践和探索,我们在对前端工程化设计中得到如下一些经验总结:\n\n- 前端开发应该\"自成体系\"(包括构建、部署及前端运维),不应该和后端项目耦合在一起。\n- 避免\"大而全\"的重量级框架,一个框架真的满足不了所有的业务场景。项目多了,我们又不想为每个新项目重新造一遍技术\"轮子\"。\n- 新的前端技术([React](https://facebook.github.io/react/)、[Vue](https://vuejs.org/)、[Angular2](https://angular.io/)等)和工具([Grunt](http://gruntjs.com)/[gulp](http://gulpjs.com)、[webpack](https://webpack.github.io)、[Babel](https://babeljs.io)等)不断涌现、迭代,新技术选型应避免\"改头换面\"式重构。\n- 工程化设计要合理分层且相互独立,随时应对新需求和技术的变化,任何一层能够低成本被替换、淘汰。\n\n#### 设计概览\n\n目前,app-proto 将前端工程化项目拆分成三大模块:Node 服务(负责数据代理、url 路由和服务端渲染)、Web 应用开发(专注 Web 交互体验)以及前端运维(构建、测试、部署及监控等)。整体的结构设计如图 1 所示。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/structural-design.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"Structural Design\" />\n <figcaption>app-proto 结构设计图</figcaption>\n</figure>\n\n- Node 服务:用于实现前后端分离,核心功能是实现数据代理中转,附带 url 路由分发和服务端渲染功能。\n- Web 应用开发:纯粹的前端模块,给予前端工程师极大的自由度进行技术选型,专注于 Web 交互体验的开发。\n- 前端运维:主要指前端项目构建和部署、工程质量(源码质量检查和测试等)及监控服务(日志、性能等)等工作。\n\n#### 前后端分离\n\n正如前文所强调的,前端模块开发应该\"自成体系\",而不是后端项目的一部分(Controller 或 View 层)。比如说,前端工程师要在本地跑通完整的项目,就必须配置好后端所需开发环境和各种服务,如果后端涉及的服务多、变化频繁,配置开发联调环境工作往往是耗时耗力的。为了实现彻底的前后端分离,我们在前端开发体系中引入了 Node 服务层。\n\n在最初的开发中,为了降低 Node 端的开发和运营成本,我们极力避免在 Node 服务中\"掺合\"过多的业务逻辑。经过几个项目的实践,最后\"约定\"在 Node 服务中我们仅仅做三件事:数据代理、路由分发和服务端渲染。\n\n##### 数据代理\n\n首先,前端数据从何而来?通过 Ajax 的形式直接从后端服务中获取数据是传统的方式,但是在应对多后端服务时,还是面临着诸如请求认证、CORS([Cross-origin resource sharing](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing))等困扰。常见的解决方案是通过[http-proxy](https://github.com/nodejitsu/node-http-proxy),即在 Node 端通过 HTTP 请求得到数据后,Web 端再通过 Ajax 的方式从 Node 端间接获取后端数据,Node 服务起到\"桥梁\"的作用。\n\n方案`http-proxy`对已经成熟的后端服务是具备实用价值的,但是在后端服务并没有完成开发(或前后端并行开发)的场景下时,开发阶段前端的数据来源依旧是个问题。同时,前端还面临诸多请求合并、缓存等需求,解决这些困扰,前端工程师需要和后端技术人员做大量的沟通、约定。\n\n在这里,我们基于原有的`http-proxy`基础上在 Node 服务中添加`datasources`模块,尝试在数据的处理上给予前端工程师很大的自由度,并实现\"按照约定写代码\"。\n\n举例说明,开发某一前端业务时涉及到`pms`和`upm`两个后端服务,且提供的 API 内容如下:\n\n```bash\n# pms API\npms/api/v2.01/login\npms/api/v2.01/inn/create\npms/api/v2.01/inn/get\n\n# upm API\nupm/api/v3.15/menu\n```\n\n面对这些接口,理想情况下前端直接通过`ajax.post('pms/api/v2.01/login', params)`方式获取即可。但是,`pms`接口服务尚处在开发阶段,面临跨域或不可用问题。`upm`接口服务虽稳定,但是该服务由第三方团队维护,请求需要权限认证。传统的 Ajax 方式在这类场景下并不适用。而`datasources`模块是通过怎样的设计来优化这些问题的呢?首先,我们将前端需要的 API 映射到前端源码仓库,映射的目录结构如下:\n\n```bash\n# server/datasources/{后端系统}/{接口目录}\n── datasources\n ├── pms\n │ ├── login.js\n │ ├── login.json\n │ └── inn\n │ ├── create.js\n │ └── get.js\n └── upm\n ├── menu.js\n └── menu.json\n```\n\n其中,每个`**.js`后缀的文件的内容是将原本 Web 端 Ajax 操作转移到 Node 端的 HTTP 请求,以`pms/login.js`为例:\n\n```js\n/* async 函数 */\nexport default async function (params) {\n const http = this.http\n const pms = this.config.api.pms\n try {\n const apiUri = `${pms.prefix}/login`\n // http 请求:http.post() 方法封装了权限认证\n const result = await http.post(apiUri, params)\n\n // 简单的数据格式校验\n if (Number(result.status) === 0 && 'data' in result && 'bid' in result.data) {\n // 将bid值记录至session\n this.session.bid = result.data.bid\n }\n return result\n } catch (e) {\n // 后端API出现异常 (实时通知 or 记录日志)\n }\n return null\n}\n```\n\n当然,对于那些已经成熟稳定的 API 服务直接通过`http-proxy`方式实现数据中转即可。但由于需求变更频繁,后端 API 服务始终处在不断迭代中,前端在进行数据处理过程中总会面临如下的几种情况:\n\n- 接口校验或数据二次加工:面临多后端服务,API 的格式可能不一致;或者对数据列表排序加工等。\n- 合并请求:可以发多个 http 请求,避免 Web 端同时发送多个 Ajax 请求。\n- 前端运维的数据:比如城市字典、阴阳历转换表等固定数据。\n- 缓存数据:如请求的用户信息,短期内不会有大变动,可以采用[Half-life cache](https://github.com/th507/node-hl-cache)等算法实现简单缓存。\n- 需权限认证的接口:[HTTP Authentication](https://tools.ietf.org/html/rfc2617)。\n\n这些场景下都建议使用`datasources`模块进行数据中转,将原本需由前后端沟通协调才能实现的功能全部交给前端自行处理,给予前端工程师处理数据提供自由度的同时也降低了后端 API 的开发维度。\n\n那该如何快捷地调用`datasources`目录下的`async`函数呢?这里我们做了简单封装,将该目录下的所有`**.js`文件解析到 Koa 的上下文环境中以`this.ds`对象进行存储,并按照目录结构进行驼峰式([Camel-Case](https://en.wikipedia.org/wiki/Camel_case))命名,转换过程见图 2。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/datasources-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"datasources-recipes\" />\n <figcaption>datasources 目录解析转换过程</figcaption>\n</figure>\n\n在 Koa 中间件中通过`this.ds`对象调用,比如`src/datasources/pms/login.js`函数映射至`this.ds.PmsLogin()`:\n\n```js\n// Koa Middlewares\napp.use(async (ctx, next) => {\n // ..`.\n // 最后一个参数为是否使用mock\n const loginData = await this.ds.PmsLogin(params, false)\n // ...\n})\n```\n\n在 Web 端可以统一封装`ds()`方法,无需关注 Ajax 请求`Headers`、是否跨域等问题:\n\n```js\n// Web (Browser)\nds('PmsLogin', { username, password }, true).then(success).catch(error)\n```\n\n##### Mock 支持\n\n正如前文所提到的,后端研发进度一般滞后于前端,在后端 API 服务可用之前,前端仅有一份 API 文档供参考。在规范中,`**.json`后缀的文件就起到 Mock 作用,同样以`pms/login.json`举例:\n\n```json\n{\n \"status\": 0,\n \"message\": \"成功\",\n \"data\": { \"bid\": \"@string(32)\", \"innCount\": 1 }\n}\n```\n\n> 具体的`json`格式写法请参考[mockjs](http://mockjs.com/)、[Syntax Specification](https://github.com/nuysoft/Mock/wiki/Syntax-Specification)。\n\n简言之,当 API 服务可用时则执行`**.js`后缀文件中的`async`函数来获取数据,不可用时则解析`**.json`后缀 Mock 文件,并不需要单独开启一个 Mock 服务。\n\n#### 路由分发\n\n对 url 路由的处理和数据代理的做法类似,按照目录结构来管理。url 路由配置在`server/pages`目录下,目录下的文件会自动映射成为路由。\n\n比如 url 为`http://example.com/pms`页面,映射到`server/pages/pms.js`文件的写法如下:\n\n```js\nexport default {\n urls: ['/pms', '/pms/error'], // 多种正则如:['/pms', ['/pms/v1'], ['/pms/v**']]\n methods: ['GET'], // 多种method:['GET', 'POST']\n js: ['http://code.jquery.com/jquery-1.12.0.min.js'],\n css: ['http://yui.yahooapis.com/pure/0.6.0/pure-min.css'],\n template: 'default', // 服务端渲染模板\n middlewares: [], // 针对本页面的中间件\n controller: async function (next) {\n // Koa中间件最后一环\n // 可以从this.ds对象中拿数据\n const loginData = await this.ds.PmsLogin(params)\n return { foo: '来自服务端数据', loginData }\n },\n}\n```\n\n由于`urls`支持多种正则,原则上每个根 url 映射`server/pages/`目录下一个`**.js`文件,映射关系如图 3 所示。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/pages-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>pages目录文件与url映射关系</figcaption>\n</figure>\n\n如果对`js`、`css`、`template`没有特殊设置(采用默认设置)的情况下,可精简如下:\n\n```js\nexport default {\n urls: ['/pms', '/pms/error'],\n controller: async function (next) {\n const loginData = await this.ds.PmsLogin(params)\n return { foo: '来自服务端数据', loginData }\n },\n}\n```\n\n需要注意的是,`controller`项是 Koa 中间件的最后一环,要求其返回值是可序列化的对象用于模板渲染的服务端参数,在此处也可以进行权限校验、从`this.ds`对象中拿数据等操作。\n\n##### 服务端渲染\n\nNode 服务端最后一个核心功能是渲染:输出 HTML Shell 和 JSON。输出 JSON 字符串的用途是为了浏览器端能以 Ajax 形式动态获取数据,而输出的 HTML 内容则是我们 Web 应用的所需的 HTML\"壳子\"。\n\n正如前文提到我们的业务特点是\"一种运行于浏览器的工具软件\",重操作交互、无 SEO 需求。因此,同构([Isomorphic JavaScript](http://isomorphic.net/))不是强需求,不是每次都要依赖服务器来重复处理逻辑和数据。服务端只需要渲染简单完善的 HTML 结构即可,具体的页面内容则由客户端 JavaScript 实现。简言之,不鼓励将前端 JavaScript 脚本再在 Node 服务端重复执行一遍。\n\n> 如果了解过 Google 推崇的 [Progressive Web App](https://developers.google.com/web/progressive-web-apps/),你可以参考《[The App Shell Model](https://developers.google.com/web/fundamentals/architecture/app-shell)》一文来理解 HTML\"壳子\"更多的用途。\n\n渲染最简单的 HTML\"壳子\"如下:\n\n```html\n<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>app-proto</title>\n <script>\n window.serveData = { foo: '来自服务端数据' }\n </script>\n </head>\n <body>\n <div id=\"app\"></div>\n <script src=\"//cdn/file-5917b08e4c7569d461b1.js\"></script>\n </body>\n</html>\n```\n\n提供简单的服务端数据`window.serveData`供客户端使用,更多渲染则由`//cdn/file-5917b08e4c7569d461b1.js`进行增量控制。\n\n###### 静态资源与 Node 端衔接\n\n那 Web 端构建的静态资源是如何 Node 服务端做衔接的呢?前端静态资源构建工作与 Node 服务相互分离,Node 服务在开启的过程中会读取前端构建生成的静态资源映射表。前端的构建过程如图 4 所示,在构建工作完成之后会生成`assets.json`静态资源映射表。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/static-file-map.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>静态资源映射文件assets.json构建</figcaption>\n</figure>\n\n> 前端构建工具基本都提供静态资源映射表生成插件,比如构建工具 Webpack 就存在插件[assets-webpack-plugin](https://github.com/kossnocorp/assets-webpack-plugin)来实现该功能。\n\n生成的`assets.json`映射表内容参考如下:\n\n```js\n{\n \"index\": // 对应的页面(url: example.com/index)\n { \"js\":\"//s0.example.net/pms/index-2abb99.js\" }, // 涉及到的静态资源列表(带版本号)\n \"login\":\n { \"js\":\"//s0.example.net/pms/login-5917b0.js\" }\n}\n```\n\n比如在渲染页面`example.com/index`时,Node 服务会以`index`作为键值,读取`assets.json`中带版本号的静态资源 CDN 地址列表,用于在\"壳子\"中与前端资源的衔接工作。\n\n### Web 端的一些\"约定\"\n\nWeb 端的技术选项是没有强制性限制的,无论你采用何种构建工具、前端库,只要生成符合约定供 Node 端使用的`assets.json`文件即可。\n\n前端工程师可以根据具体的业务特点、团队技术喜好来选取合理的开发方案,无论是 React、Vue 还是 Angular2 并不做强限制。尽管给予 Web 前端开发很大的自由度,但是鼓励遵循下面几条\"约定\":\n\n- Ajax 请求从 Node 端代理,而非具体后端服务。\n- 鼓励将 JavaScript、CSS、HTML 视为前端领域的\"汇编\"。\n- 重视前端页面状态管理,推荐的方案有[Redux](https://github.com/reactjs/redux)、[vuex](https://github.com/vuejs/vuex)及[MobX](https://github.com/mobxjs/mobx)等。\n- 强调组件化,面向组件集开发。\n\n这里重点强调下面向组件集的前端开发。在项目初期我们一般不会马上投入到业务开发,而是针对设计师和产品经理提供的设计稿、产品原型图实现一套组件集或选择合适的开源组件集,积累好基础组件集后再投入到具体业务开发。\n\n在进行前端技术调研时,该技术是否有配套的开源组件集往往是我们考虑的重点。比如基于 React 实现的开源组件集[ant.design](http://ant.design/)、[Material-UI](http://www.material-ui.com/)等,我们部分前端项目都直接或间接的使用到了,极大地减少了研发成本。\n\n当然,美团点评内部也提供一个组件中心平台(可参考[美团点评前端组件中心介绍 Slide](https://slides.com/solome/mt-components-hub/live#/)),鼓励大家将各自项目中的有价值组件分享出来,实现组件跨项目复用。\n\n#### 工程化支持\n\n##### 项目脚手架\n\n项目脚手架的作用是在启动一个新项目时,通过几个简单命令就能快速搭建好项目的开发环境。我们基于[Yeoman](http://yeoman.io/)构建了一个完整的项目脚手架。\n\n```bash\n# 安装脚手架\n$ npm install -g yo\n$ npm install -g @ia/generator-app-proto@latest\n# 初始化新项目(进行简单选择)\n$ yo @ia/app-proto\n```\n\n##### 工程质量保障\n\n我们重视项目的每次`commit`,同个项目要求遵循同一套编码规范,并采用[ESLint](http://eslint.org/)等工具进行约束,对于一些复用性高的核心组件也强制要求写测试。\n为保障项目质量,每个项目都要求接入美团点评基于[Stash](https://en.wikipedia.org/wiki/Stash_(software)实现的Castle CI 系统,每次的源码提交都会自动执行一遍 ESLint、测试和构建,并生成构建日志通过公司内部沟通工具大象进行实时消息推送。\n\n##### 标准化测试环境管理\n\n美团点评内部提供了基于 Docker 实现的测试环境管理服务 Cargo,用于提升测试和联调测试效率,促进 DevOps 开发模式。将项目接入到 Cargo 服务后,只需在仓库中提供简单的配置文件`cargo.yml`(配置参考如下),就会自动生成一套测试环境。\n\n```bash\n# 依赖的镜像\nimage: registry.cargo.example.com/node:v4.2.1\n# 容器占用的端口\nports:\n - '8998'\n# 环境变量\nenv:\n - COMMON_VARIABLE = 'true'\n - NODE_ENV = 'cargo'\n - DEBUG = 'app-proto,datasource.*'\n# 收集的日志文件\nlogs:\n - error = /var/path/logs/app-proto/error.log\n - out = /var/path/logs/app-proto/out.log\n# 构建脚本\nbuild_script: bin/pre-deploy-staging\n# 运行脚本\nrun_script: bin/cargo-start\n```\n\n#### 总结\n\n前端工程化体系的引入,让前端开发能和原生 App 应用项目开发一样\"自成体系\",脱离了对后端项目的依赖。基于\"约定优于配置\"、\"按照约定写代码\"的原则对 Node 层功能的设定能够降低沟通协调成本,构建、部署等工作的规范化,使前端技术人员的开发重点回归到 Web 应用的交互体验本身,回归到\"纯粹\"的前端研发。\n","source":"_posts/tech-salon-13-app-proto.md","raw":"---\ntitle: '前端工程化开发方案 app-proto'\ndate: 2017-01-05 20:00:00 +0800\ncomments: true\ncategories: 技术分享\n---\n\n> 本文是针对去年(2016 年 10 月)美团点评技术沙龙第 13 期分享的[《前端工程化开发方案 app-proto 介绍》](https://www.slideshare.net/meituan/13appproto)整理而来的技术博客。\n> 年初的时候又针对 Koa2.0 做了些许语法适配和完整的 SSR 支持([app-proto-2.0](https://solome.js.org/slides/app-proto-2.0/))。\n\n什么是前端工程化?根据具体的业务特点,将前端的开发流程、技术、工具、经验等规范化、标准化就是前端工程化。\n它的目的是让前端开发能够\"自成体系\",最大程度地提高前端工程师的开发效率,降低技术选型、前后端联调等带来的协调沟通成本。\n\n美团点评厦门智能住宿前端研发团队通过多个前端项目开发的探索和实践,基于\"约定优于配置\"([Convention Over Configuration](https://en.wikipedia.org/wiki/Convention_over_configuration))的原则制定了一套前端工程化开发方案 app-proto。本文将简要介绍其中的一些设计细节和约定。\n\n#### 面临的业务特点\n\n智能住宿前端团队承担的前端业务主要面向 B 端项目,用户主要是商家、销售、运营、产品经理以及研发人员。\n\n诸如工单管理、信息管理、门锁运营、PMS([Property management system](https://en.wikipedia.org/wiki/Property_management_system))、CRM([Customer relationship management](https://en.wikipedia.org/wiki/Customer_relationship_management))及 AMS([Asset management system](https://en.wikipedia.org/wiki/Asset_management))等项目都是单页面工具类应用,特点是功能交互繁多、复杂表单,非展示类、无 SEO([Search engine optimization](https://en.wikipedia.org/wiki/Search_engine_optimization))需求。\n\n如果这些项目脱离浏览器这个\"外壳\",与传统的原生桌面 GUI 软件无异。换言之,这些项目就是一种运行于浏览器的工具软件。\n\n> 实际上,部分项目我们也确实利用 CEF([Chromium Embedded Framework](https://bitbucket.org/chromiumembedded/cef))等技术给其套个\"外壳\",当作传统的桌面 GUI 应用提供给用户使用。\n\n同时,部分服务需要从智能门锁、控制盒 Wifi 等硬件设备收录状态数据,限于硬件环境测试的不稳定性,后端的开发测试周期远比前端开发周期长。大部分场景下,前后端需并行开发,后端工程师并不能在第一时间兼顾到前端所需的 API 接口等服务,给前端开发造成没有必要的\"等待期\",影响开发进度。\n\n此外,项目多、敏捷需求多、开发周期短以及面向多后端服务(多个后端团队)等也是我们前端研发团队面临的挑战。\n\n#### 一些前端经验总结\n\n针对多个项目的开发实践和探索,我们在对前端工程化设计中得到如下一些经验总结:\n\n- 前端开发应该\"自成体系\"(包括构建、部署及前端运维),不应该和后端项目耦合在一起。\n- 避免\"大而全\"的重量级框架,一个框架真的满足不了所有的业务场景。项目多了,我们又不想为每个新项目重新造一遍技术\"轮子\"。\n- 新的前端技术([React](https://facebook.github.io/react/)、[Vue](https://vuejs.org/)、[Angular2](https://angular.io/)等)和工具([Grunt](http://gruntjs.com)/[gulp](http://gulpjs.com)、[webpack](https://webpack.github.io)、[Babel](https://babeljs.io)等)不断涌现、迭代,新技术选型应避免\"改头换面\"式重构。\n- 工程化设计要合理分层且相互独立,随时应对新需求和技术的变化,任何一层能够低成本被替换、淘汰。\n\n#### 设计概览\n\n目前,app-proto 将前端工程化项目拆分成三大模块:Node 服务(负责数据代理、url 路由和服务端渲染)、Web 应用开发(专注 Web 交互体验)以及前端运维(构建、测试、部署及监控等)。整体的结构设计如图 1 所示。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/structural-design.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"Structural Design\" />\n <figcaption>app-proto 结构设计图</figcaption>\n</figure>\n\n- Node 服务:用于实现前后端分离,核心功能是实现数据代理中转,附带 url 路由分发和服务端渲染功能。\n- Web 应用开发:纯粹的前端模块,给予前端工程师极大的自由度进行技术选型,专注于 Web 交互体验的开发。\n- 前端运维:主要指前端项目构建和部署、工程质量(源码质量检查和测试等)及监控服务(日志、性能等)等工作。\n\n#### 前后端分离\n\n正如前文所强调的,前端模块开发应该\"自成体系\",而不是后端项目的一部分(Controller 或 View 层)。比如说,前端工程师要在本地跑通完整的项目,就必须配置好后端所需开发环境和各种服务,如果后端涉及的服务多、变化频繁,配置开发联调环境工作往往是耗时耗力的。为了实现彻底的前后端分离,我们在前端开发体系中引入了 Node 服务层。\n\n在最初的开发中,为了降低 Node 端的开发和运营成本,我们极力避免在 Node 服务中\"掺合\"过多的业务逻辑。经过几个项目的实践,最后\"约定\"在 Node 服务中我们仅仅做三件事:数据代理、路由分发和服务端渲染。\n\n##### 数据代理\n\n首先,前端数据从何而来?通过 Ajax 的形式直接从后端服务中获取数据是传统的方式,但是在应对多后端服务时,还是面临着诸如请求认证、CORS([Cross-origin resource sharing](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing))等困扰。常见的解决方案是通过[http-proxy](https://github.com/nodejitsu/node-http-proxy),即在 Node 端通过 HTTP 请求得到数据后,Web 端再通过 Ajax 的方式从 Node 端间接获取后端数据,Node 服务起到\"桥梁\"的作用。\n\n方案`http-proxy`对已经成熟的后端服务是具备实用价值的,但是在后端服务并没有完成开发(或前后端并行开发)的场景下时,开发阶段前端的数据来源依旧是个问题。同时,前端还面临诸多请求合并、缓存等需求,解决这些困扰,前端工程师需要和后端技术人员做大量的沟通、约定。\n\n在这里,我们基于原有的`http-proxy`基础上在 Node 服务中添加`datasources`模块,尝试在数据的处理上给予前端工程师很大的自由度,并实现\"按照约定写代码\"。\n\n举例说明,开发某一前端业务时涉及到`pms`和`upm`两个后端服务,且提供的 API 内容如下:\n\n```bash\n# pms API\npms/api/v2.01/login\npms/api/v2.01/inn/create\npms/api/v2.01/inn/get\n\n# upm API\nupm/api/v3.15/menu\n```\n\n面对这些接口,理想情况下前端直接通过`ajax.post('pms/api/v2.01/login', params)`方式获取即可。但是,`pms`接口服务尚处在开发阶段,面临跨域或不可用问题。`upm`接口服务虽稳定,但是该服务由第三方团队维护,请求需要权限认证。传统的 Ajax 方式在这类场景下并不适用。而`datasources`模块是通过怎样的设计来优化这些问题的呢?首先,我们将前端需要的 API 映射到前端源码仓库,映射的目录结构如下:\n\n```bash\n# server/datasources/{后端系统}/{接口目录}\n── datasources\n ├── pms\n │ ├── login.js\n │ ├── login.json\n │ └── inn\n │ ├── create.js\n │ └── get.js\n └── upm\n ├── menu.js\n └── menu.json\n```\n\n其中,每个`**.js`后缀的文件的内容是将原本 Web 端 Ajax 操作转移到 Node 端的 HTTP 请求,以`pms/login.js`为例:\n\n```js\n/* async 函数 */\nexport default async function (params) {\n const http = this.http\n const pms = this.config.api.pms\n try {\n const apiUri = `${pms.prefix}/login`\n // http 请求:http.post() 方法封装了权限认证\n const result = await http.post(apiUri, params)\n\n // 简单的数据格式校验\n if (Number(result.status) === 0 && 'data' in result && 'bid' in result.data) {\n // 将bid值记录至session\n this.session.bid = result.data.bid\n }\n return result\n } catch (e) {\n // 后端API出现异常 (实时通知 or 记录日志)\n }\n return null\n}\n```\n\n当然,对于那些已经成熟稳定的 API 服务直接通过`http-proxy`方式实现数据中转即可。但由于需求变更频繁,后端 API 服务始终处在不断迭代中,前端在进行数据处理过程中总会面临如下的几种情况:\n\n- 接口校验或数据二次加工:面临多后端服务,API 的格式可能不一致;或者对数据列表排序加工等。\n- 合并请求:可以发多个 http 请求,避免 Web 端同时发送多个 Ajax 请求。\n- 前端运维的数据:比如城市字典、阴阳历转换表等固定数据。\n- 缓存数据:如请求的用户信息,短期内不会有大变动,可以采用[Half-life cache](https://github.com/th507/node-hl-cache)等算法实现简单缓存。\n- 需权限认证的接口:[HTTP Authentication](https://tools.ietf.org/html/rfc2617)。\n\n这些场景下都建议使用`datasources`模块进行数据中转,将原本需由前后端沟通协调才能实现的功能全部交给前端自行处理,给予前端工程师处理数据提供自由度的同时也降低了后端 API 的开发维度。\n\n那该如何快捷地调用`datasources`目录下的`async`函数呢?这里我们做了简单封装,将该目录下的所有`**.js`文件解析到 Koa 的上下文环境中以`this.ds`对象进行存储,并按照目录结构进行驼峰式([Camel-Case](https://en.wikipedia.org/wiki/Camel_case))命名,转换过程见图 2。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/datasources-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"datasources-recipes\" />\n <figcaption>datasources 目录解析转换过程</figcaption>\n</figure>\n\n在 Koa 中间件中通过`this.ds`对象调用,比如`src/datasources/pms/login.js`函数映射至`this.ds.PmsLogin()`:\n\n```js\n// Koa Middlewares\napp.use(async (ctx, next) => {\n // ..`.\n // 最后一个参数为是否使用mock\n const loginData = await this.ds.PmsLogin(params, false)\n // ...\n})\n```\n\n在 Web 端可以统一封装`ds()`方法,无需关注 Ajax 请求`Headers`、是否跨域等问题:\n\n```js\n// Web (Browser)\nds('PmsLogin', { username, password }, true).then(success).catch(error)\n```\n\n##### Mock 支持\n\n正如前文所提到的,后端研发进度一般滞后于前端,在后端 API 服务可用之前,前端仅有一份 API 文档供参考。在规范中,`**.json`后缀的文件就起到 Mock 作用,同样以`pms/login.json`举例:\n\n```json\n{\n \"status\": 0,\n \"message\": \"成功\",\n \"data\": { \"bid\": \"@string(32)\", \"innCount\": 1 }\n}\n```\n\n> 具体的`json`格式写法请参考[mockjs](http://mockjs.com/)、[Syntax Specification](https://github.com/nuysoft/Mock/wiki/Syntax-Specification)。\n\n简言之,当 API 服务可用时则执行`**.js`后缀文件中的`async`函数来获取数据,不可用时则解析`**.json`后缀 Mock 文件,并不需要单独开启一个 Mock 服务。\n\n#### 路由分发\n\n对 url 路由的处理和数据代理的做法类似,按照目录结构来管理。url 路由配置在`server/pages`目录下,目录下的文件会自动映射成为路由。\n\n比如 url 为`http://example.com/pms`页面,映射到`server/pages/pms.js`文件的写法如下:\n\n```js\nexport default {\n urls: ['/pms', '/pms/error'], // 多种正则如:['/pms', ['/pms/v1'], ['/pms/v**']]\n methods: ['GET'], // 多种method:['GET', 'POST']\n js: ['http://code.jquery.com/jquery-1.12.0.min.js'],\n css: ['http://yui.yahooapis.com/pure/0.6.0/pure-min.css'],\n template: 'default', // 服务端渲染模板\n middlewares: [], // 针对本页面的中间件\n controller: async function (next) {\n // Koa中间件最后一环\n // 可以从this.ds对象中拿数据\n const loginData = await this.ds.PmsLogin(params)\n return { foo: '来自服务端数据', loginData }\n },\n}\n```\n\n由于`urls`支持多种正则,原则上每个根 url 映射`server/pages/`目录下一个`**.js`文件,映射关系如图 3 所示。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/pages-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>pages目录文件与url映射关系</figcaption>\n</figure>\n\n如果对`js`、`css`、`template`没有特殊设置(采用默认设置)的情况下,可精简如下:\n\n```js\nexport default {\n urls: ['/pms', '/pms/error'],\n controller: async function (next) {\n const loginData = await this.ds.PmsLogin(params)\n return { foo: '来自服务端数据', loginData }\n },\n}\n```\n\n需要注意的是,`controller`项是 Koa 中间件的最后一环,要求其返回值是可序列化的对象用于模板渲染的服务端参数,在此处也可以进行权限校验、从`this.ds`对象中拿数据等操作。\n\n##### 服务端渲染\n\nNode 服务端最后一个核心功能是渲染:输出 HTML Shell 和 JSON。输出 JSON 字符串的用途是为了浏览器端能以 Ajax 形式动态获取数据,而输出的 HTML 内容则是我们 Web 应用的所需的 HTML\"壳子\"。\n\n正如前文提到我们的业务特点是\"一种运行于浏览器的工具软件\",重操作交互、无 SEO 需求。因此,同构([Isomorphic JavaScript](http://isomorphic.net/))不是强需求,不是每次都要依赖服务器来重复处理逻辑和数据。服务端只需要渲染简单完善的 HTML 结构即可,具体的页面内容则由客户端 JavaScript 实现。简言之,不鼓励将前端 JavaScript 脚本再在 Node 服务端重复执行一遍。\n\n> 如果了解过 Google 推崇的 [Progressive Web App](https://developers.google.com/web/progressive-web-apps/),你可以参考《[The App Shell Model](https://developers.google.com/web/fundamentals/architecture/app-shell)》一文来理解 HTML\"壳子\"更多的用途。\n\n渲染最简单的 HTML\"壳子\"如下:\n\n```html\n<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>app-proto</title>\n <script>\n window.serveData = { foo: '来自服务端数据' }\n </script>\n </head>\n <body>\n <div id=\"app\"></div>\n <script src=\"//cdn/file-5917b08e4c7569d461b1.js\"></script>\n </body>\n</html>\n```\n\n提供简单的服务端数据`window.serveData`供客户端使用,更多渲染则由`//cdn/file-5917b08e4c7569d461b1.js`进行增量控制。\n\n###### 静态资源与 Node 端衔接\n\n那 Web 端构建的静态资源是如何 Node 服务端做衔接的呢?前端静态资源构建工作与 Node 服务相互分离,Node 服务在开启的过程中会读取前端构建生成的静态资源映射表。前端的构建过程如图 4 所示,在构建工作完成之后会生成`assets.json`静态资源映射表。\n\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/static-file-map.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>静态资源映射文件assets.json构建</figcaption>\n</figure>\n\n> 前端构建工具基本都提供静态资源映射表生成插件,比如构建工具 Webpack 就存在插件[assets-webpack-plugin](https://github.com/kossnocorp/assets-webpack-plugin)来实现该功能。\n\n生成的`assets.json`映射表内容参考如下:\n\n```js\n{\n \"index\": // 对应的页面(url: example.com/index)\n { \"js\":\"//s0.example.net/pms/index-2abb99.js\" }, // 涉及到的静态资源列表(带版本号)\n \"login\":\n { \"js\":\"//s0.example.net/pms/login-5917b0.js\" }\n}\n```\n\n比如在渲染页面`example.com/index`时,Node 服务会以`index`作为键值,读取`assets.json`中带版本号的静态资源 CDN 地址列表,用于在\"壳子\"中与前端资源的衔接工作。\n\n### Web 端的一些\"约定\"\n\nWeb 端的技术选项是没有强制性限制的,无论你采用何种构建工具、前端库,只要生成符合约定供 Node 端使用的`assets.json`文件即可。\n\n前端工程师可以根据具体的业务特点、团队技术喜好来选取合理的开发方案,无论是 React、Vue 还是 Angular2 并不做强限制。尽管给予 Web 前端开发很大的自由度,但是鼓励遵循下面几条\"约定\":\n\n- Ajax 请求从 Node 端代理,而非具体后端服务。\n- 鼓励将 JavaScript、CSS、HTML 视为前端领域的\"汇编\"。\n- 重视前端页面状态管理,推荐的方案有[Redux](https://github.com/reactjs/redux)、[vuex](https://github.com/vuejs/vuex)及[MobX](https://github.com/mobxjs/mobx)等。\n- 强调组件化,面向组件集开发。\n\n这里重点强调下面向组件集的前端开发。在项目初期我们一般不会马上投入到业务开发,而是针对设计师和产品经理提供的设计稿、产品原型图实现一套组件集或选择合适的开源组件集,积累好基础组件集后再投入到具体业务开发。\n\n在进行前端技术调研时,该技术是否有配套的开源组件集往往是我们考虑的重点。比如基于 React 实现的开源组件集[ant.design](http://ant.design/)、[Material-UI](http://www.material-ui.com/)等,我们部分前端项目都直接或间接的使用到了,极大地减少了研发成本。\n\n当然,美团点评内部也提供一个组件中心平台(可参考[美团点评前端组件中心介绍 Slide](https://slides.com/solome/mt-components-hub/live#/)),鼓励大家将各自项目中的有价值组件分享出来,实现组件跨项目复用。\n\n#### 工程化支持\n\n##### 项目脚手架\n\n项目脚手架的作用是在启动一个新项目时,通过几个简单命令就能快速搭建好项目的开发环境。我们基于[Yeoman](http://yeoman.io/)构建了一个完整的项目脚手架。\n\n```bash\n# 安装脚手架\n$ npm install -g yo\n$ npm install -g @ia/generator-app-proto@latest\n# 初始化新项目(进行简单选择)\n$ yo @ia/app-proto\n```\n\n##### 工程质量保障\n\n我们重视项目的每次`commit`,同个项目要求遵循同一套编码规范,并采用[ESLint](http://eslint.org/)等工具进行约束,对于一些复用性高的核心组件也强制要求写测试。\n为保障项目质量,每个项目都要求接入美团点评基于[Stash](https://en.wikipedia.org/wiki/Stash_(software)实现的Castle CI 系统,每次的源码提交都会自动执行一遍 ESLint、测试和构建,并生成构建日志通过公司内部沟通工具大象进行实时消息推送。\n\n##### 标准化测试环境管理\n\n美团点评内部提供了基于 Docker 实现的测试环境管理服务 Cargo,用于提升测试和联调测试效率,促进 DevOps 开发模式。将项目接入到 Cargo 服务后,只需在仓库中提供简单的配置文件`cargo.yml`(配置参考如下),就会自动生成一套测试环境。\n\n```bash\n# 依赖的镜像\nimage: registry.cargo.example.com/node:v4.2.1\n# 容器占用的端口\nports:\n - '8998'\n# 环境变量\nenv:\n - COMMON_VARIABLE = 'true'\n - NODE_ENV = 'cargo'\n - DEBUG = 'app-proto,datasource.*'\n# 收集的日志文件\nlogs:\n - error = /var/path/logs/app-proto/error.log\n - out = /var/path/logs/app-proto/out.log\n# 构建脚本\nbuild_script: bin/pre-deploy-staging\n# 运行脚本\nrun_script: bin/cargo-start\n```\n\n#### 总结\n\n前端工程化体系的引入,让前端开发能和原生 App 应用项目开发一样\"自成体系\",脱离了对后端项目的依赖。基于\"约定优于配置\"、\"按照约定写代码\"的原则对 Node 层功能的设定能够降低沟通协调成本,构建、部署等工作的规范化,使前端技术人员的开发重点回归到 Web 应用的交互体验本身,回归到\"纯粹\"的前端研发。\n","slug":"tech-salon-13-app-proto","published":1,"updated":"2023-11-07T16:02:42.778Z","_id":"clooia2m1000bln3y0n7khjqs","layout":"post","photos":[],"link":"","content":"<blockquote>\n<p>本文是针对去年(2016 年 10 月)美团点评技术沙龙第 13 期分享的<a href=\"https://www.slideshare.net/meituan/13appproto\">《前端工程化开发方案 app-proto 介绍》</a>整理而来的技术博客。<br>年初的时候又针对 Koa2.0 做了些许语法适配和完整的 SSR 支持(<a href=\"https://solome.js.org/slides/app-proto-2.0/\">app-proto-2.0</a>)。</p>\n</blockquote>\n<p>什么是前端工程化?根据具体的业务特点,将前端的开发流程、技术、工具、经验等规范化、标准化就是前端工程化。<br>它的目的是让前端开发能够"自成体系",最大程度地提高前端工程师的开发效率,降低技术选型、前后端联调等带来的协调沟通成本。</p>\n<p>美团点评厦门智能住宿前端研发团队通过多个前端项目开发的探索和实践,基于"约定优于配置"(<a href=\"https://en.wikipedia.org/wiki/Convention_over_configuration\">Convention Over Configuration</a>)的原则制定了一套前端工程化开发方案 app-proto。本文将简要介绍其中的一些设计细节和约定。</p>\n<h4 id=\"面临的业务特点\"><a href=\"#面临的业务特点\" class=\"headerlink\" title=\"面临的业务特点\"></a>面临的业务特点</h4><p>智能住宿前端团队承担的前端业务主要面向 B 端项目,用户主要是商家、销售、运营、产品经理以及研发人员。</p>\n<p>诸如工单管理、信息管理、门锁运营、PMS(<a href=\"https://en.wikipedia.org/wiki/Property_management_system\">Property management system</a>)、CRM(<a href=\"https://en.wikipedia.org/wiki/Customer_relationship_management\">Customer relationship management</a>)及 AMS(<a href=\"https://en.wikipedia.org/wiki/Asset_management\">Asset management system</a>)等项目都是单页面工具类应用,特点是功能交互繁多、复杂表单,非展示类、无 SEO(<a href=\"https://en.wikipedia.org/wiki/Search_engine_optimization\">Search engine optimization</a>)需求。</p>\n<p>如果这些项目脱离浏览器这个"外壳",与传统的原生桌面 GUI 软件无异。换言之,这些项目就是一种运行于浏览器的工具软件。</p>\n<blockquote>\n<p>实际上,部分项目我们也确实利用 CEF(<a href=\"https://bitbucket.org/chromiumembedded/cef\">Chromium Embedded Framework</a>)等技术给其套个"外壳",当作传统的桌面 GUI 应用提供给用户使用。</p>\n</blockquote>\n<p>同时,部分服务需要从智能门锁、控制盒 Wifi 等硬件设备收录状态数据,限于硬件环境测试的不稳定性,后端的开发测试周期远比前端开发周期长。大部分场景下,前后端需并行开发,后端工程师并不能在第一时间兼顾到前端所需的 API 接口等服务,给前端开发造成没有必要的"等待期",影响开发进度。</p>\n<p>此外,项目多、敏捷需求多、开发周期短以及面向多后端服务(多个后端团队)等也是我们前端研发团队面临的挑战。</p>\n<h4 id=\"一些前端经验总结\"><a href=\"#一些前端经验总结\" class=\"headerlink\" title=\"一些前端经验总结\"></a>一些前端经验总结</h4><p>针对多个项目的开发实践和探索,我们在对前端工程化设计中得到如下一些经验总结:</p>\n<ul>\n<li>前端开发应该"自成体系"(包括构建、部署及前端运维),不应该和后端项目耦合在一起。</li>\n<li>避免"大而全"的重量级框架,一个框架真的满足不了所有的业务场景。项目多了,我们又不想为每个新项目重新造一遍技术"轮子"。</li>\n<li>新的前端技术(<a href=\"https://facebook.github.io/react/\">React</a>、<a href=\"https://vuejs.org/\">Vue</a>、<a href=\"https://angular.io/\">Angular2</a>等)和工具(<a href=\"http://gruntjs.com/\">Grunt</a>/<a href=\"http://gulpjs.com/\">gulp</a>、<a href=\"https://webpack.github.io/\">webpack</a>、<a href=\"https://babeljs.io/\">Babel</a>等)不断涌现、迭代,新技术选型应避免"改头换面"式重构。</li>\n<li>工程化设计要合理分层且相互独立,随时应对新需求和技术的变化,任何一层能够低成本被替换、淘汰。</li>\n</ul>\n<h4 id=\"设计概览\"><a href=\"#设计概览\" class=\"headerlink\" title=\"设计概览\"></a>设计概览</h4><p>目前,app-proto 将前端工程化项目拆分成三大模块:Node 服务(负责数据代理、url 路由和服务端渲染)、Web 应用开发(专注 Web 交互体验)以及前端运维(构建、测试、部署及监控等)。整体的结构设计如图 1 所示。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/structural-design.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"Structural Design\" />\n <figcaption>app-proto 结构设计图</figcaption>\n</figure>\n\n<ul>\n<li>Node 服务:用于实现前后端分离,核心功能是实现数据代理中转,附带 url 路由分发和服务端渲染功能。</li>\n<li>Web 应用开发:纯粹的前端模块,给予前端工程师极大的自由度进行技术选型,专注于 Web 交互体验的开发。</li>\n<li>前端运维:主要指前端项目构建和部署、工程质量(源码质量检查和测试等)及监控服务(日志、性能等)等工作。</li>\n</ul>\n<h4 id=\"前后端分离\"><a href=\"#前后端分离\" class=\"headerlink\" title=\"前后端分离\"></a>前后端分离</h4><p>正如前文所强调的,前端模块开发应该"自成体系",而不是后端项目的一部分(Controller 或 View 层)。比如说,前端工程师要在本地跑通完整的项目,就必须配置好后端所需开发环境和各种服务,如果后端涉及的服务多、变化频繁,配置开发联调环境工作往往是耗时耗力的。为了实现彻底的前后端分离,我们在前端开发体系中引入了 Node 服务层。</p>\n<p>在最初的开发中,为了降低 Node 端的开发和运营成本,我们极力避免在 Node 服务中"掺合"过多的业务逻辑。经过几个项目的实践,最后"约定"在 Node 服务中我们仅仅做三件事:数据代理、路由分发和服务端渲染。</p>\n<h5 id=\"数据代理\"><a href=\"#数据代理\" class=\"headerlink\" title=\"数据代理\"></a>数据代理</h5><p>首先,前端数据从何而来?通过 Ajax 的形式直接从后端服务中获取数据是传统的方式,但是在应对多后端服务时,还是面临着诸如请求认证、CORS(<a href=\"https://en.wikipedia.org/wiki/Cross-origin_resource_sharing\">Cross-origin resource sharing</a>)等困扰。常见的解决方案是通过<a href=\"https://github.com/nodejitsu/node-http-proxy\">http-proxy</a>,即在 Node 端通过 HTTP 请求得到数据后,Web 端再通过 Ajax 的方式从 Node 端间接获取后端数据,Node 服务起到"桥梁"的作用。</p>\n<p>方案<code>http-proxy</code>对已经成熟的后端服务是具备实用价值的,但是在后端服务并没有完成开发(或前后端并行开发)的场景下时,开发阶段前端的数据来源依旧是个问题。同时,前端还面临诸多请求合并、缓存等需求,解决这些困扰,前端工程师需要和后端技术人员做大量的沟通、约定。</p>\n<p>在这里,我们基于原有的<code>http-proxy</code>基础上在 Node 服务中添加<code>datasources</code>模块,尝试在数据的处理上给予前端工程师很大的自由度,并实现"按照约定写代码"。</p>\n<p>举例说明,开发某一前端业务时涉及到<code>pms</code>和<code>upm</code>两个后端服务,且提供的 API 内容如下:</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># pms API</span><br>pms/api/v2.01/login<br>pms/api/v2.01/inn/create<br>pms/api/v2.01/inn/get<br><br><span class=\"hljs-comment\"># upm API</span><br>upm/api/v3.15/menu<br></code></pre></td></tr></table></figure>\n\n<p>面对这些接口,理想情况下前端直接通过<code>ajax.post('pms/api/v2.01/login', params)</code>方式获取即可。但是,<code>pms</code>接口服务尚处在开发阶段,面临跨域或不可用问题。<code>upm</code>接口服务虽稳定,但是该服务由第三方团队维护,请求需要权限认证。传统的 Ajax 方式在这类场景下并不适用。而<code>datasources</code>模块是通过怎样的设计来优化这些问题的呢?首先,我们将前端需要的 API 映射到前端源码仓库,映射的目录结构如下:</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># server/datasources/{后端系统}/{接口目录}</span><br>── datasources<br> ├── pms<br> │ ├── login.js<br> │ ├── login.json<br> │ └── inn<br> │ ├── create.js<br> │ └── get.js<br> └── upm<br> ├── menu.js<br> └── menu.json<br></code></pre></td></tr></table></figure>\n\n<p>其中,每个<code>**.js</code>后缀的文件的内容是将原本 Web 端 Ajax 操作转移到 Node 端的 HTTP 请求,以<code>pms/login.js</code>为例:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">/* async 函数 */</span><br><span class=\"hljs-keyword\">export</span> <span class=\"hljs-keyword\">default</span> <span class=\"hljs-keyword\">async</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\">params</span>) </span>{<br> <span class=\"hljs-keyword\">const</span> http = <span class=\"hljs-built_in\">this</span>.http<br> <span class=\"hljs-keyword\">const</span> pms = <span class=\"hljs-built_in\">this</span>.config.api.pms<br> <span class=\"hljs-keyword\">try</span> {<br> <span class=\"hljs-keyword\">const</span> apiUri = <span class=\"hljs-string\">`<span class=\"hljs-subst\">${pms.prefix}</span>/login`</span><br> <span class=\"hljs-comment\">// http 请求:http.post() 方法封装了权限认证</span><br> <span class=\"hljs-keyword\">const</span> result = <span class=\"hljs-keyword\">await</span> http.post(apiUri, params)<br><br> <span class=\"hljs-comment\">// 简单的数据格式校验</span><br> <span class=\"hljs-keyword\">if</span> (<span class=\"hljs-built_in\">Number</span>(result.status) === <span class=\"hljs-number\">0</span> && <span class=\"hljs-string\">'data'</span> <span class=\"hljs-keyword\">in</span> result && <span class=\"hljs-string\">'bid'</span> <span class=\"hljs-keyword\">in</span> result.data) {<br> <span class=\"hljs-comment\">// 将bid值记录至session</span><br> <span class=\"hljs-built_in\">this</span>.session.bid = result.data.bid<br> }<br> <span class=\"hljs-keyword\">return</span> result<br> } <span class=\"hljs-keyword\">catch</span> (e) {<br> <span class=\"hljs-comment\">// 后端API出现异常 (实时通知 or 记录日志)</span><br> }<br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-literal\">null</span><br>}<br></code></pre></td></tr></table></figure>\n\n<p>当然,对于那些已经成熟稳定的 API 服务直接通过<code>http-proxy</code>方式实现数据中转即可。但由于需求变更频繁,后端 API 服务始终处在不断迭代中,前端在进行数据处理过程中总会面临如下的几种情况:</p>\n<ul>\n<li>接口校验或数据二次加工:面临多后端服务,API 的格式可能不一致;或者对数据列表排序加工等。</li>\n<li>合并请求:可以发多个 http 请求,避免 Web 端同时发送多个 Ajax 请求。</li>\n<li>前端运维的数据:比如城市字典、阴阳历转换表等固定数据。</li>\n<li>缓存数据:如请求的用户信息,短期内不会有大变动,可以采用<a href=\"https://github.com/th507/node-hl-cache\">Half-life cache</a>等算法实现简单缓存。</li>\n<li>需权限认证的接口:<a href=\"https://tools.ietf.org/html/rfc2617\">HTTP Authentication</a>。</li>\n</ul>\n<p>这些场景下都建议使用<code>datasources</code>模块进行数据中转,将原本需由前后端沟通协调才能实现的功能全部交给前端自行处理,给予前端工程师处理数据提供自由度的同时也降低了后端 API 的开发维度。</p>\n<p>那该如何快捷地调用<code>datasources</code>目录下的<code>async</code>函数呢?这里我们做了简单封装,将该目录下的所有<code>**.js</code>文件解析到 Koa 的上下文环境中以<code>this.ds</code>对象进行存储,并按照目录结构进行驼峰式(<a href=\"https://en.wikipedia.org/wiki/Camel_case\">Camel-Case</a>)命名,转换过程见图 2。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/datasources-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"datasources-recipes\" />\n <figcaption>datasources 目录解析转换过程</figcaption>\n</figure>\n\n<p>在 Koa 中间件中通过<code>this.ds</code>对象调用,比如<code>src/datasources/pms/login.js</code>函数映射至<code>this.ds.PmsLogin()</code>:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">// Koa Middlewares</span><br>app.use(<span class=\"hljs-keyword\">async</span> (ctx, next) => {<br> <span class=\"hljs-comment\">// ..`.</span><br> <span class=\"hljs-comment\">// 最后一个参数为是否使用mock</span><br> <span class=\"hljs-keyword\">const</span> loginData = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-built_in\">this</span>.ds.PmsLogin(params, <span class=\"hljs-literal\">false</span>)<br> <span class=\"hljs-comment\">// ...</span><br>})<br></code></pre></td></tr></table></figure>\n\n<p>在 Web 端可以统一封装<code>ds()</code>方法,无需关注 Ajax 请求<code>Headers</code>、是否跨域等问题:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">// Web (Browser)</span><br>ds(<span class=\"hljs-string\">'PmsLogin'</span>, { username, password }, <span class=\"hljs-literal\">true</span>).then(success).catch(error)<br></code></pre></td></tr></table></figure>\n\n<h5 id=\"Mock-支持\"><a href=\"#Mock-支持\" class=\"headerlink\" title=\"Mock 支持\"></a>Mock 支持</h5><p>正如前文所提到的,后端研发进度一般滞后于前端,在后端 API 服务可用之前,前端仅有一份 API 文档供参考。在规范中,<code>**.json</code>后缀的文件就起到 Mock 作用,同样以<code>pms/login.json</code>举例:</p>\n<figure class=\"highlight json\"><table><tr><td class=\"code\"><pre><code class=\"hljs json\">{<br> <span class=\"hljs-attr\">"status"</span>: <span class=\"hljs-number\">0</span>,<br> <span class=\"hljs-attr\">"message"</span>: <span class=\"hljs-string\">"成功"</span>,<br> <span class=\"hljs-attr\">"data"</span>: { <span class=\"hljs-attr\">"bid"</span>: <span class=\"hljs-string\">"@string(32)"</span>, <span class=\"hljs-attr\">"innCount"</span>: <span class=\"hljs-number\">1</span> }<br>}<br></code></pre></td></tr></table></figure>\n\n<blockquote>\n<p>具体的<code>json</code>格式写法请参考<a href=\"http://mockjs.com/\">mockjs</a>、<a href=\"https://github.com/nuysoft/Mock/wiki/Syntax-Specification\">Syntax Specification</a>。</p>\n</blockquote>\n<p>简言之,当 API 服务可用时则执行<code>**.js</code>后缀文件中的<code>async</code>函数来获取数据,不可用时则解析<code>**.json</code>后缀 Mock 文件,并不需要单独开启一个 Mock 服务。</p>\n<h4 id=\"路由分发\"><a href=\"#路由分发\" class=\"headerlink\" title=\"路由分发\"></a>路由分发</h4><p>对 url 路由的处理和数据代理的做法类似,按照目录结构来管理。url 路由配置在<code>server/pages</code>目录下,目录下的文件会自动映射成为路由。</p>\n<p>比如 url 为<code>http://example.com/pms</code>页面,映射到<code>server/pages/pms.js</code>文件的写法如下:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">export</span> <span class=\"hljs-keyword\">default</span> {<br> <span class=\"hljs-attr\">urls</span>: [<span class=\"hljs-string\">'/pms'</span>, <span class=\"hljs-string\">'/pms/error'</span>], <span class=\"hljs-comment\">// 多种正则如:['/pms', ['/pms/v1'], ['/pms/v**']]</span><br> <span class=\"hljs-attr\">methods</span>: [<span class=\"hljs-string\">'GET'</span>], <span class=\"hljs-comment\">// 多种method:['GET', 'POST']</span><br> <span class=\"hljs-attr\">js</span>: [<span class=\"hljs-string\">'http://code.jquery.com/jquery-1.12.0.min.js'</span>],<br> <span class=\"hljs-attr\">css</span>: [<span class=\"hljs-string\">'http://yui.yahooapis.com/pure/0.6.0/pure-min.css'</span>],<br> <span class=\"hljs-attr\">template</span>: <span class=\"hljs-string\">'default'</span>, <span class=\"hljs-comment\">// 服务端渲染模板</span><br> <span class=\"hljs-attr\">middlewares</span>: [], <span class=\"hljs-comment\">// 针对本页面的中间件</span><br> <span class=\"hljs-attr\">controller</span>: <span class=\"hljs-keyword\">async</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\">next</span>) </span>{<br> <span class=\"hljs-comment\">// Koa中间件最后一环</span><br> <span class=\"hljs-comment\">// 可以从this.ds对象中拿数据</span><br> <span class=\"hljs-keyword\">const</span> loginData = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-built_in\">this</span>.ds.PmsLogin(params)<br> <span class=\"hljs-keyword\">return</span> { <span class=\"hljs-attr\">foo</span>: <span class=\"hljs-string\">'来自服务端数据'</span>, loginData }<br> },<br>}<br></code></pre></td></tr></table></figure>\n\n<p>由于<code>urls</code>支持多种正则,原则上每个根 url 映射<code>server/pages/</code>目录下一个<code>**.js</code>文件,映射关系如图 3 所示。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/pages-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>pages目录文件与url映射关系</figcaption>\n</figure>\n\n<p>如果对<code>js</code>、<code>css</code>、<code>template</code>没有特殊设置(采用默认设置)的情况下,可精简如下:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">export</span> <span class=\"hljs-keyword\">default</span> {<br> <span class=\"hljs-attr\">urls</span>: [<span class=\"hljs-string\">'/pms'</span>, <span class=\"hljs-string\">'/pms/error'</span>],<br> <span class=\"hljs-attr\">controller</span>: <span class=\"hljs-keyword\">async</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\">next</span>) </span>{<br> <span class=\"hljs-keyword\">const</span> loginData = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-built_in\">this</span>.ds.PmsLogin(params)<br> <span class=\"hljs-keyword\">return</span> { <span class=\"hljs-attr\">foo</span>: <span class=\"hljs-string\">'来自服务端数据'</span>, loginData }<br> },<br>}<br></code></pre></td></tr></table></figure>\n\n<p>需要注意的是,<code>controller</code>项是 Koa 中间件的最后一环,要求其返回值是可序列化的对象用于模板渲染的服务端参数,在此处也可以进行权限校验、从<code>this.ds</code>对象中拿数据等操作。</p>\n<h5 id=\"服务端渲染\"><a href=\"#服务端渲染\" class=\"headerlink\" title=\"服务端渲染\"></a>服务端渲染</h5><p>Node 服务端最后一个核心功能是渲染:输出 HTML Shell 和 JSON。输出 JSON 字符串的用途是为了浏览器端能以 Ajax 形式动态获取数据,而输出的 HTML 内容则是我们 Web 应用的所需的 HTML"壳子"。</p>\n<p>正如前文提到我们的业务特点是"一种运行于浏览器的工具软件",重操作交互、无 SEO 需求。因此,同构(<a href=\"http://isomorphic.net/\">Isomorphic JavaScript</a>)不是强需求,不是每次都要依赖服务器来重复处理逻辑和数据。服务端只需要渲染简单完善的 HTML 结构即可,具体的页面内容则由客户端 JavaScript 实现。简言之,不鼓励将前端 JavaScript 脚本再在 Node 服务端重复执行一遍。</p>\n<blockquote>\n<p>如果了解过 Google 推崇的 <a href=\"https://developers.google.com/web/progressive-web-apps/\">Progressive Web App</a>,你可以参考《<a href=\"https://developers.google.com/web/fundamentals/architecture/app-shell\">The App Shell Model</a>》一文来理解 HTML"壳子"更多的用途。</p>\n</blockquote>\n<p>渲染最简单的 HTML"壳子"如下:</p>\n<figure class=\"highlight html\"><table><tr><td class=\"code\"><pre><code class=\"hljs html\"><span class=\"hljs-meta\"><!DOCTYPE <span class=\"hljs-meta-keyword\">html</span>></span><br><span class=\"hljs-tag\"><<span class=\"hljs-name\">html</span> <span class=\"hljs-attr\">lang</span>=<span class=\"hljs-string\">"en"</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">head</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">meta</span> <span class=\"hljs-attr\">charset</span>=<span class=\"hljs-string\">"utf-8"</span> /></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">title</span>></span>app-proto<span class=\"hljs-tag\"></<span class=\"hljs-name\">title</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">script</span>></span><span class=\"javascript\"></span><br><span class=\"javascript\"> <span class=\"hljs-built_in\">window</span>.serveData = { <span class=\"hljs-attr\">foo</span>: <span class=\"hljs-string\">'来自服务端数据'</span> }</span><br><span class=\"javascript\"> </span><span class=\"hljs-tag\"></<span class=\"hljs-name\">script</span>></span><br> <span class=\"hljs-tag\"></<span class=\"hljs-name\">head</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">body</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">div</span> <span class=\"hljs-attr\">id</span>=<span class=\"hljs-string\">"app"</span>></span><span class=\"hljs-tag\"></<span class=\"hljs-name\">div</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">script</span> <span class=\"hljs-attr\">src</span>=<span class=\"hljs-string\">"//cdn/file-5917b08e4c7569d461b1.js"</span>></span><span class=\"hljs-tag\"></<span class=\"hljs-name\">script</span>></span><br> <span class=\"hljs-tag\"></<span class=\"hljs-name\">body</span>></span><br><span class=\"hljs-tag\"></<span class=\"hljs-name\">html</span>></span><br></code></pre></td></tr></table></figure>\n\n<p>提供简单的服务端数据<code>window.serveData</code>供客户端使用,更多渲染则由<code>//cdn/file-5917b08e4c7569d461b1.js</code>进行增量控制。</p>\n<h6 id=\"静态资源与-Node-端衔接\"><a href=\"#静态资源与-Node-端衔接\" class=\"headerlink\" title=\"静态资源与 Node 端衔接\"></a>静态资源与 Node 端衔接</h6><p>那 Web 端构建的静态资源是如何 Node 服务端做衔接的呢?前端静态资源构建工作与 Node 服务相互分离,Node 服务在开启的过程中会读取前端构建生成的静态资源映射表。前端的构建过程如图 4 所示,在构建工作完成之后会生成<code>assets.json</code>静态资源映射表。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/static-file-map.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>静态资源映射文件assets.json构建</figcaption>\n</figure>\n\n<blockquote>\n<p>前端构建工具基本都提供静态资源映射表生成插件,比如构建工具 Webpack 就存在插件<a href=\"https://github.com/kossnocorp/assets-webpack-plugin\">assets-webpack-plugin</a>来实现该功能。</p>\n</blockquote>\n<p>生成的<code>assets.json</code>映射表内容参考如下:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\">{<br> <span class=\"hljs-string\">"index"</span>: <span class=\"hljs-comment\">// 对应的页面(url: example.com/index)</span><br> { <span class=\"hljs-string\">"js"</span>:<span class=\"hljs-string\">"//s0.example.net/pms/index-2abb99.js"</span> }, <span class=\"hljs-comment\">// 涉及到的静态资源列表(带版本号)</span><br> <span class=\"hljs-string\">"login"</span>:<br> { <span class=\"hljs-string\">"js"</span>:<span class=\"hljs-string\">"//s0.example.net/pms/login-5917b0.js"</span> }<br>}<br></code></pre></td></tr></table></figure>\n\n<p>比如在渲染页面<code>example.com/index</code>时,Node 服务会以<code>index</code>作为键值,读取<code>assets.json</code>中带版本号的静态资源 CDN 地址列表,用于在"壳子"中与前端资源的衔接工作。</p>\n<h3 id=\"Web-端的一些-quot-约定-quot\"><a href=\"#Web-端的一些-quot-约定-quot\" class=\"headerlink\" title=\"Web 端的一些"约定"\"></a>Web 端的一些"约定"</h3><p>Web 端的技术选项是没有强制性限制的,无论你采用何种构建工具、前端库,只要生成符合约定供 Node 端使用的<code>assets.json</code>文件即可。</p>\n<p>前端工程师可以根据具体的业务特点、团队技术喜好来选取合理的开发方案,无论是 React、Vue 还是 Angular2 并不做强限制。尽管给予 Web 前端开发很大的自由度,但是鼓励遵循下面几条"约定":</p>\n<ul>\n<li>Ajax 请求从 Node 端代理,而非具体后端服务。</li>\n<li>鼓励将 JavaScript、CSS、HTML 视为前端领域的"汇编"。</li>\n<li>重视前端页面状态管理,推荐的方案有<a href=\"https://github.com/reactjs/redux\">Redux</a>、<a href=\"https://github.com/vuejs/vuex\">vuex</a>及<a href=\"https://github.com/mobxjs/mobx\">MobX</a>等。</li>\n<li>强调组件化,面向组件集开发。</li>\n</ul>\n<p>这里重点强调下面向组件集的前端开发。在项目初期我们一般不会马上投入到业务开发,而是针对设计师和产品经理提供的设计稿、产品原型图实现一套组件集或选择合适的开源组件集,积累好基础组件集后再投入到具体业务开发。</p>\n<p>在进行前端技术调研时,该技术是否有配套的开源组件集往往是我们考虑的重点。比如基于 React 实现的开源组件集<a href=\"http://ant.design/\">ant.design</a>、<a href=\"http://www.material-ui.com/\">Material-UI</a>等,我们部分前端项目都直接或间接的使用到了,极大地减少了研发成本。</p>\n<p>当然,美团点评内部也提供一个组件中心平台(可参考<a href=\"https://slides.com/solome/mt-components-hub/live#/\">美团点评前端组件中心介绍 Slide</a>),鼓励大家将各自项目中的有价值组件分享出来,实现组件跨项目复用。</p>\n<h4 id=\"工程化支持\"><a href=\"#工程化支持\" class=\"headerlink\" title=\"工程化支持\"></a>工程化支持</h4><h5 id=\"项目脚手架\"><a href=\"#项目脚手架\" class=\"headerlink\" title=\"项目脚手架\"></a>项目脚手架</h5><p>项目脚手架的作用是在启动一个新项目时,通过几个简单命令就能快速搭建好项目的开发环境。我们基于<a href=\"http://yeoman.io/\">Yeoman</a>构建了一个完整的项目脚手架。</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># 安装脚手架</span><br>$ npm install -g yo<br>$ npm install -g @ia/generator-app-proto@latest<br><span class=\"hljs-comment\"># 初始化新项目(进行简单选择)</span><br>$ yo @ia/app-proto<br></code></pre></td></tr></table></figure>\n\n<h5 id=\"工程质量保障\"><a href=\"#工程质量保障\" class=\"headerlink\" title=\"工程质量保障\"></a>工程质量保障</h5><p>我们重视项目的每次<code>commit</code>,同个项目要求遵循同一套编码规范,并采用<a href=\"http://eslint.org/\">ESLint</a>等工具进行约束,对于一些复用性高的核心组件也强制要求写测试。<br>为保障项目质量,每个项目都要求接入美团点评基于<a href=\"https://en.wikipedia.org/wiki/Stash_(software\">Stash</a>实现的Castle CI 系统,每次的源码提交都会自动执行一遍 ESLint、测试和构建,并生成构建日志通过公司内部沟通工具大象进行实时消息推送。</p>\n<h5 id=\"标准化测试环境管理\"><a href=\"#标准化测试环境管理\" class=\"headerlink\" title=\"标准化测试环境管理\"></a>标准化测试环境管理</h5><p>美团点评内部提供了基于 Docker 实现的测试环境管理服务 Cargo,用于提升测试和联调测试效率,促进 DevOps 开发模式。将项目接入到 Cargo 服务后,只需在仓库中提供简单的配置文件<code>cargo.yml</code>(配置参考如下),就会自动生成一套测试环境。</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># 依赖的镜像</span><br>image: registry.cargo.example.com/node:v4.2.1<br><span class=\"hljs-comment\"># 容器占用的端口</span><br>ports:<br> - <span class=\"hljs-string\">'8998'</span><br><span class=\"hljs-comment\"># 环境变量</span><br>env:<br> - COMMON_VARIABLE = <span class=\"hljs-string\">'true'</span><br> - NODE_ENV = <span class=\"hljs-string\">'cargo'</span><br> - DEBUG = <span class=\"hljs-string\">'app-proto,datasource.*'</span><br><span class=\"hljs-comment\"># 收集的日志文件</span><br>logs:<br> - error = /var/path/logs/app-proto/error.log<br> - out = /var/path/logs/app-proto/out.log<br><span class=\"hljs-comment\"># 构建脚本</span><br>build_script: bin/pre-deploy-staging<br><span class=\"hljs-comment\"># 运行脚本</span><br>run_script: bin/cargo-start<br></code></pre></td></tr></table></figure>\n\n<h4 id=\"总结\"><a href=\"#总结\" class=\"headerlink\" title=\"总结\"></a>总结</h4><p>前端工程化体系的引入,让前端开发能和原生 App 应用项目开发一样"自成体系",脱离了对后端项目的依赖。基于"约定优于配置"、"按照约定写代码"的原则对 Node 层功能的设定能够降低沟通协调成本,构建、部署等工作的规范化,使前端技术人员的开发重点回归到 Web 应用的交互体验本身,回归到"纯粹"的前端研发。</p>\n","site":{"data":{}},"excerpt":"","more":"<blockquote>\n<p>本文是针对去年(2016 年 10 月)美团点评技术沙龙第 13 期分享的<a href=\"https://www.slideshare.net/meituan/13appproto\">《前端工程化开发方案 app-proto 介绍》</a>整理而来的技术博客。<br>年初的时候又针对 Koa2.0 做了些许语法适配和完整的 SSR 支持(<a href=\"https://solome.js.org/slides/app-proto-2.0/\">app-proto-2.0</a>)。</p>\n</blockquote>\n<p>什么是前端工程化?根据具体的业务特点,将前端的开发流程、技术、工具、经验等规范化、标准化就是前端工程化。<br>它的目的是让前端开发能够"自成体系",最大程度地提高前端工程师的开发效率,降低技术选型、前后端联调等带来的协调沟通成本。</p>\n<p>美团点评厦门智能住宿前端研发团队通过多个前端项目开发的探索和实践,基于"约定优于配置"(<a href=\"https://en.wikipedia.org/wiki/Convention_over_configuration\">Convention Over Configuration</a>)的原则制定了一套前端工程化开发方案 app-proto。本文将简要介绍其中的一些设计细节和约定。</p>\n<h4 id=\"面临的业务特点\"><a href=\"#面临的业务特点\" class=\"headerlink\" title=\"面临的业务特点\"></a>面临的业务特点</h4><p>智能住宿前端团队承担的前端业务主要面向 B 端项目,用户主要是商家、销售、运营、产品经理以及研发人员。</p>\n<p>诸如工单管理、信息管理、门锁运营、PMS(<a href=\"https://en.wikipedia.org/wiki/Property_management_system\">Property management system</a>)、CRM(<a href=\"https://en.wikipedia.org/wiki/Customer_relationship_management\">Customer relationship management</a>)及 AMS(<a href=\"https://en.wikipedia.org/wiki/Asset_management\">Asset management system</a>)等项目都是单页面工具类应用,特点是功能交互繁多、复杂表单,非展示类、无 SEO(<a href=\"https://en.wikipedia.org/wiki/Search_engine_optimization\">Search engine optimization</a>)需求。</p>\n<p>如果这些项目脱离浏览器这个"外壳",与传统的原生桌面 GUI 软件无异。换言之,这些项目就是一种运行于浏览器的工具软件。</p>\n<blockquote>\n<p>实际上,部分项目我们也确实利用 CEF(<a href=\"https://bitbucket.org/chromiumembedded/cef\">Chromium Embedded Framework</a>)等技术给其套个"外壳",当作传统的桌面 GUI 应用提供给用户使用。</p>\n</blockquote>\n<p>同时,部分服务需要从智能门锁、控制盒 Wifi 等硬件设备收录状态数据,限于硬件环境测试的不稳定性,后端的开发测试周期远比前端开发周期长。大部分场景下,前后端需并行开发,后端工程师并不能在第一时间兼顾到前端所需的 API 接口等服务,给前端开发造成没有必要的"等待期",影响开发进度。</p>\n<p>此外,项目多、敏捷需求多、开发周期短以及面向多后端服务(多个后端团队)等也是我们前端研发团队面临的挑战。</p>\n<h4 id=\"一些前端经验总结\"><a href=\"#一些前端经验总结\" class=\"headerlink\" title=\"一些前端经验总结\"></a>一些前端经验总结</h4><p>针对多个项目的开发实践和探索,我们在对前端工程化设计中得到如下一些经验总结:</p>\n<ul>\n<li>前端开发应该"自成体系"(包括构建、部署及前端运维),不应该和后端项目耦合在一起。</li>\n<li>避免"大而全"的重量级框架,一个框架真的满足不了所有的业务场景。项目多了,我们又不想为每个新项目重新造一遍技术"轮子"。</li>\n<li>新的前端技术(<a href=\"https://facebook.github.io/react/\">React</a>、<a href=\"https://vuejs.org/\">Vue</a>、<a href=\"https://angular.io/\">Angular2</a>等)和工具(<a href=\"http://gruntjs.com/\">Grunt</a>/<a href=\"http://gulpjs.com/\">gulp</a>、<a href=\"https://webpack.github.io/\">webpack</a>、<a href=\"https://babeljs.io/\">Babel</a>等)不断涌现、迭代,新技术选型应避免"改头换面"式重构。</li>\n<li>工程化设计要合理分层且相互独立,随时应对新需求和技术的变化,任何一层能够低成本被替换、淘汰。</li>\n</ul>\n<h4 id=\"设计概览\"><a href=\"#设计概览\" class=\"headerlink\" title=\"设计概览\"></a>设计概览</h4><p>目前,app-proto 将前端工程化项目拆分成三大模块:Node 服务(负责数据代理、url 路由和服务端渲染)、Web 应用开发(专注 Web 交互体验)以及前端运维(构建、测试、部署及监控等)。整体的结构设计如图 1 所示。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/structural-design.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"Structural Design\" />\n <figcaption>app-proto 结构设计图</figcaption>\n</figure>\n\n<ul>\n<li>Node 服务:用于实现前后端分离,核心功能是实现数据代理中转,附带 url 路由分发和服务端渲染功能。</li>\n<li>Web 应用开发:纯粹的前端模块,给予前端工程师极大的自由度进行技术选型,专注于 Web 交互体验的开发。</li>\n<li>前端运维:主要指前端项目构建和部署、工程质量(源码质量检查和测试等)及监控服务(日志、性能等)等工作。</li>\n</ul>\n<h4 id=\"前后端分离\"><a href=\"#前后端分离\" class=\"headerlink\" title=\"前后端分离\"></a>前后端分离</h4><p>正如前文所强调的,前端模块开发应该"自成体系",而不是后端项目的一部分(Controller 或 View 层)。比如说,前端工程师要在本地跑通完整的项目,就必须配置好后端所需开发环境和各种服务,如果后端涉及的服务多、变化频繁,配置开发联调环境工作往往是耗时耗力的。为了实现彻底的前后端分离,我们在前端开发体系中引入了 Node 服务层。</p>\n<p>在最初的开发中,为了降低 Node 端的开发和运营成本,我们极力避免在 Node 服务中"掺合"过多的业务逻辑。经过几个项目的实践,最后"约定"在 Node 服务中我们仅仅做三件事:数据代理、路由分发和服务端渲染。</p>\n<h5 id=\"数据代理\"><a href=\"#数据代理\" class=\"headerlink\" title=\"数据代理\"></a>数据代理</h5><p>首先,前端数据从何而来?通过 Ajax 的形式直接从后端服务中获取数据是传统的方式,但是在应对多后端服务时,还是面临着诸如请求认证、CORS(<a href=\"https://en.wikipedia.org/wiki/Cross-origin_resource_sharing\">Cross-origin resource sharing</a>)等困扰。常见的解决方案是通过<a href=\"https://github.com/nodejitsu/node-http-proxy\">http-proxy</a>,即在 Node 端通过 HTTP 请求得到数据后,Web 端再通过 Ajax 的方式从 Node 端间接获取后端数据,Node 服务起到"桥梁"的作用。</p>\n<p>方案<code>http-proxy</code>对已经成熟的后端服务是具备实用价值的,但是在后端服务并没有完成开发(或前后端并行开发)的场景下时,开发阶段前端的数据来源依旧是个问题。同时,前端还面临诸多请求合并、缓存等需求,解决这些困扰,前端工程师需要和后端技术人员做大量的沟通、约定。</p>\n<p>在这里,我们基于原有的<code>http-proxy</code>基础上在 Node 服务中添加<code>datasources</code>模块,尝试在数据的处理上给予前端工程师很大的自由度,并实现"按照约定写代码"。</p>\n<p>举例说明,开发某一前端业务时涉及到<code>pms</code>和<code>upm</code>两个后端服务,且提供的 API 内容如下:</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># pms API</span><br>pms/api/v2.01/login<br>pms/api/v2.01/inn/create<br>pms/api/v2.01/inn/get<br><br><span class=\"hljs-comment\"># upm API</span><br>upm/api/v3.15/menu<br></code></pre></td></tr></table></figure>\n\n<p>面对这些接口,理想情况下前端直接通过<code>ajax.post('pms/api/v2.01/login', params)</code>方式获取即可。但是,<code>pms</code>接口服务尚处在开发阶段,面临跨域或不可用问题。<code>upm</code>接口服务虽稳定,但是该服务由第三方团队维护,请求需要权限认证。传统的 Ajax 方式在这类场景下并不适用。而<code>datasources</code>模块是通过怎样的设计来优化这些问题的呢?首先,我们将前端需要的 API 映射到前端源码仓库,映射的目录结构如下:</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># server/datasources/{后端系统}/{接口目录}</span><br>── datasources<br> ├── pms<br> │ ├── login.js<br> │ ├── login.json<br> │ └── inn<br> │ ├── create.js<br> │ └── get.js<br> └── upm<br> ├── menu.js<br> └── menu.json<br></code></pre></td></tr></table></figure>\n\n<p>其中,每个<code>**.js</code>后缀的文件的内容是将原本 Web 端 Ajax 操作转移到 Node 端的 HTTP 请求,以<code>pms/login.js</code>为例:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">/* async 函数 */</span><br><span class=\"hljs-keyword\">export</span> <span class=\"hljs-keyword\">default</span> <span class=\"hljs-keyword\">async</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\">params</span>) </span>{<br> <span class=\"hljs-keyword\">const</span> http = <span class=\"hljs-built_in\">this</span>.http<br> <span class=\"hljs-keyword\">const</span> pms = <span class=\"hljs-built_in\">this</span>.config.api.pms<br> <span class=\"hljs-keyword\">try</span> {<br> <span class=\"hljs-keyword\">const</span> apiUri = <span class=\"hljs-string\">`<span class=\"hljs-subst\">${pms.prefix}</span>/login`</span><br> <span class=\"hljs-comment\">// http 请求:http.post() 方法封装了权限认证</span><br> <span class=\"hljs-keyword\">const</span> result = <span class=\"hljs-keyword\">await</span> http.post(apiUri, params)<br><br> <span class=\"hljs-comment\">// 简单的数据格式校验</span><br> <span class=\"hljs-keyword\">if</span> (<span class=\"hljs-built_in\">Number</span>(result.status) === <span class=\"hljs-number\">0</span> && <span class=\"hljs-string\">'data'</span> <span class=\"hljs-keyword\">in</span> result && <span class=\"hljs-string\">'bid'</span> <span class=\"hljs-keyword\">in</span> result.data) {<br> <span class=\"hljs-comment\">// 将bid值记录至session</span><br> <span class=\"hljs-built_in\">this</span>.session.bid = result.data.bid<br> }<br> <span class=\"hljs-keyword\">return</span> result<br> } <span class=\"hljs-keyword\">catch</span> (e) {<br> <span class=\"hljs-comment\">// 后端API出现异常 (实时通知 or 记录日志)</span><br> }<br> <span class=\"hljs-keyword\">return</span> <span class=\"hljs-literal\">null</span><br>}<br></code></pre></td></tr></table></figure>\n\n<p>当然,对于那些已经成熟稳定的 API 服务直接通过<code>http-proxy</code>方式实现数据中转即可。但由于需求变更频繁,后端 API 服务始终处在不断迭代中,前端在进行数据处理过程中总会面临如下的几种情况:</p>\n<ul>\n<li>接口校验或数据二次加工:面临多后端服务,API 的格式可能不一致;或者对数据列表排序加工等。</li>\n<li>合并请求:可以发多个 http 请求,避免 Web 端同时发送多个 Ajax 请求。</li>\n<li>前端运维的数据:比如城市字典、阴阳历转换表等固定数据。</li>\n<li>缓存数据:如请求的用户信息,短期内不会有大变动,可以采用<a href=\"https://github.com/th507/node-hl-cache\">Half-life cache</a>等算法实现简单缓存。</li>\n<li>需权限认证的接口:<a href=\"https://tools.ietf.org/html/rfc2617\">HTTP Authentication</a>。</li>\n</ul>\n<p>这些场景下都建议使用<code>datasources</code>模块进行数据中转,将原本需由前后端沟通协调才能实现的功能全部交给前端自行处理,给予前端工程师处理数据提供自由度的同时也降低了后端 API 的开发维度。</p>\n<p>那该如何快捷地调用<code>datasources</code>目录下的<code>async</code>函数呢?这里我们做了简单封装,将该目录下的所有<code>**.js</code>文件解析到 Koa 的上下文环境中以<code>this.ds</code>对象进行存储,并按照目录结构进行驼峰式(<a href=\"https://en.wikipedia.org/wiki/Camel_case\">Camel-Case</a>)命名,转换过程见图 2。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/datasources-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"datasources-recipes\" />\n <figcaption>datasources 目录解析转换过程</figcaption>\n</figure>\n\n<p>在 Koa 中间件中通过<code>this.ds</code>对象调用,比如<code>src/datasources/pms/login.js</code>函数映射至<code>this.ds.PmsLogin()</code>:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">// Koa Middlewares</span><br>app.use(<span class=\"hljs-keyword\">async</span> (ctx, next) => {<br> <span class=\"hljs-comment\">// ..`.</span><br> <span class=\"hljs-comment\">// 最后一个参数为是否使用mock</span><br> <span class=\"hljs-keyword\">const</span> loginData = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-built_in\">this</span>.ds.PmsLogin(params, <span class=\"hljs-literal\">false</span>)<br> <span class=\"hljs-comment\">// ...</span><br>})<br></code></pre></td></tr></table></figure>\n\n<p>在 Web 端可以统一封装<code>ds()</code>方法,无需关注 Ajax 请求<code>Headers</code>、是否跨域等问题:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-comment\">// Web (Browser)</span><br>ds(<span class=\"hljs-string\">'PmsLogin'</span>, { username, password }, <span class=\"hljs-literal\">true</span>).then(success).catch(error)<br></code></pre></td></tr></table></figure>\n\n<h5 id=\"Mock-支持\"><a href=\"#Mock-支持\" class=\"headerlink\" title=\"Mock 支持\"></a>Mock 支持</h5><p>正如前文所提到的,后端研发进度一般滞后于前端,在后端 API 服务可用之前,前端仅有一份 API 文档供参考。在规范中,<code>**.json</code>后缀的文件就起到 Mock 作用,同样以<code>pms/login.json</code>举例:</p>\n<figure class=\"highlight json\"><table><tr><td class=\"code\"><pre><code class=\"hljs json\">{<br> <span class=\"hljs-attr\">"status"</span>: <span class=\"hljs-number\">0</span>,<br> <span class=\"hljs-attr\">"message"</span>: <span class=\"hljs-string\">"成功"</span>,<br> <span class=\"hljs-attr\">"data"</span>: { <span class=\"hljs-attr\">"bid"</span>: <span class=\"hljs-string\">"@string(32)"</span>, <span class=\"hljs-attr\">"innCount"</span>: <span class=\"hljs-number\">1</span> }<br>}<br></code></pre></td></tr></table></figure>\n\n<blockquote>\n<p>具体的<code>json</code>格式写法请参考<a href=\"http://mockjs.com/\">mockjs</a>、<a href=\"https://github.com/nuysoft/Mock/wiki/Syntax-Specification\">Syntax Specification</a>。</p>\n</blockquote>\n<p>简言之,当 API 服务可用时则执行<code>**.js</code>后缀文件中的<code>async</code>函数来获取数据,不可用时则解析<code>**.json</code>后缀 Mock 文件,并不需要单独开启一个 Mock 服务。</p>\n<h4 id=\"路由分发\"><a href=\"#路由分发\" class=\"headerlink\" title=\"路由分发\"></a>路由分发</h4><p>对 url 路由的处理和数据代理的做法类似,按照目录结构来管理。url 路由配置在<code>server/pages</code>目录下,目录下的文件会自动映射成为路由。</p>\n<p>比如 url 为<code>http://example.com/pms</code>页面,映射到<code>server/pages/pms.js</code>文件的写法如下:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">export</span> <span class=\"hljs-keyword\">default</span> {<br> <span class=\"hljs-attr\">urls</span>: [<span class=\"hljs-string\">'/pms'</span>, <span class=\"hljs-string\">'/pms/error'</span>], <span class=\"hljs-comment\">// 多种正则如:['/pms', ['/pms/v1'], ['/pms/v**']]</span><br> <span class=\"hljs-attr\">methods</span>: [<span class=\"hljs-string\">'GET'</span>], <span class=\"hljs-comment\">// 多种method:['GET', 'POST']</span><br> <span class=\"hljs-attr\">js</span>: [<span class=\"hljs-string\">'http://code.jquery.com/jquery-1.12.0.min.js'</span>],<br> <span class=\"hljs-attr\">css</span>: [<span class=\"hljs-string\">'http://yui.yahooapis.com/pure/0.6.0/pure-min.css'</span>],<br> <span class=\"hljs-attr\">template</span>: <span class=\"hljs-string\">'default'</span>, <span class=\"hljs-comment\">// 服务端渲染模板</span><br> <span class=\"hljs-attr\">middlewares</span>: [], <span class=\"hljs-comment\">// 针对本页面的中间件</span><br> <span class=\"hljs-attr\">controller</span>: <span class=\"hljs-keyword\">async</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\">next</span>) </span>{<br> <span class=\"hljs-comment\">// Koa中间件最后一环</span><br> <span class=\"hljs-comment\">// 可以从this.ds对象中拿数据</span><br> <span class=\"hljs-keyword\">const</span> loginData = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-built_in\">this</span>.ds.PmsLogin(params)<br> <span class=\"hljs-keyword\">return</span> { <span class=\"hljs-attr\">foo</span>: <span class=\"hljs-string\">'来自服务端数据'</span>, loginData }<br> },<br>}<br></code></pre></td></tr></table></figure>\n\n<p>由于<code>urls</code>支持多种正则,原则上每个根 url 映射<code>server/pages/</code>目录下一个<code>**.js</code>文件,映射关系如图 3 所示。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/pages-recipes.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>pages目录文件与url映射关系</figcaption>\n</figure>\n\n<p>如果对<code>js</code>、<code>css</code>、<code>template</code>没有特殊设置(采用默认设置)的情况下,可精简如下:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\"><span class=\"hljs-keyword\">export</span> <span class=\"hljs-keyword\">default</span> {<br> <span class=\"hljs-attr\">urls</span>: [<span class=\"hljs-string\">'/pms'</span>, <span class=\"hljs-string\">'/pms/error'</span>],<br> <span class=\"hljs-attr\">controller</span>: <span class=\"hljs-keyword\">async</span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function</span> (<span class=\"hljs-params\">next</span>) </span>{<br> <span class=\"hljs-keyword\">const</span> loginData = <span class=\"hljs-keyword\">await</span> <span class=\"hljs-built_in\">this</span>.ds.PmsLogin(params)<br> <span class=\"hljs-keyword\">return</span> { <span class=\"hljs-attr\">foo</span>: <span class=\"hljs-string\">'来自服务端数据'</span>, loginData }<br> },<br>}<br></code></pre></td></tr></table></figure>\n\n<p>需要注意的是,<code>controller</code>项是 Koa 中间件的最后一环,要求其返回值是可序列化的对象用于模板渲染的服务端参数,在此处也可以进行权限校验、从<code>this.ds</code>对象中拿数据等操作。</p>\n<h5 id=\"服务端渲染\"><a href=\"#服务端渲染\" class=\"headerlink\" title=\"服务端渲染\"></a>服务端渲染</h5><p>Node 服务端最后一个核心功能是渲染:输出 HTML Shell 和 JSON。输出 JSON 字符串的用途是为了浏览器端能以 Ajax 形式动态获取数据,而输出的 HTML 内容则是我们 Web 应用的所需的 HTML"壳子"。</p>\n<p>正如前文提到我们的业务特点是"一种运行于浏览器的工具软件",重操作交互、无 SEO 需求。因此,同构(<a href=\"http://isomorphic.net/\">Isomorphic JavaScript</a>)不是强需求,不是每次都要依赖服务器来重复处理逻辑和数据。服务端只需要渲染简单完善的 HTML 结构即可,具体的页面内容则由客户端 JavaScript 实现。简言之,不鼓励将前端 JavaScript 脚本再在 Node 服务端重复执行一遍。</p>\n<blockquote>\n<p>如果了解过 Google 推崇的 <a href=\"https://developers.google.com/web/progressive-web-apps/\">Progressive Web App</a>,你可以参考《<a href=\"https://developers.google.com/web/fundamentals/architecture/app-shell\">The App Shell Model</a>》一文来理解 HTML"壳子"更多的用途。</p>\n</blockquote>\n<p>渲染最简单的 HTML"壳子"如下:</p>\n<figure class=\"highlight html\"><table><tr><td class=\"code\"><pre><code class=\"hljs html\"><span class=\"hljs-meta\"><!DOCTYPE <span class=\"hljs-meta-keyword\">html</span>></span><br><span class=\"hljs-tag\"><<span class=\"hljs-name\">html</span> <span class=\"hljs-attr\">lang</span>=<span class=\"hljs-string\">"en"</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">head</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">meta</span> <span class=\"hljs-attr\">charset</span>=<span class=\"hljs-string\">"utf-8"</span> /></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">title</span>></span>app-proto<span class=\"hljs-tag\"></<span class=\"hljs-name\">title</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">script</span>></span><span class=\"javascript\"></span><br><span class=\"javascript\"> <span class=\"hljs-built_in\">window</span>.serveData = { <span class=\"hljs-attr\">foo</span>: <span class=\"hljs-string\">'来自服务端数据'</span> }</span><br><span class=\"javascript\"> </span><span class=\"hljs-tag\"></<span class=\"hljs-name\">script</span>></span><br> <span class=\"hljs-tag\"></<span class=\"hljs-name\">head</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">body</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">div</span> <span class=\"hljs-attr\">id</span>=<span class=\"hljs-string\">"app"</span>></span><span class=\"hljs-tag\"></<span class=\"hljs-name\">div</span>></span><br> <span class=\"hljs-tag\"><<span class=\"hljs-name\">script</span> <span class=\"hljs-attr\">src</span>=<span class=\"hljs-string\">"//cdn/file-5917b08e4c7569d461b1.js"</span>></span><span class=\"hljs-tag\"></<span class=\"hljs-name\">script</span>></span><br> <span class=\"hljs-tag\"></<span class=\"hljs-name\">body</span>></span><br><span class=\"hljs-tag\"></<span class=\"hljs-name\">html</span>></span><br></code></pre></td></tr></table></figure>\n\n<p>提供简单的服务端数据<code>window.serveData</code>供客户端使用,更多渲染则由<code>//cdn/file-5917b08e4c7569d461b1.js</code>进行增量控制。</p>\n<h6 id=\"静态资源与-Node-端衔接\"><a href=\"#静态资源与-Node-端衔接\" class=\"headerlink\" title=\"静态资源与 Node 端衔接\"></a>静态资源与 Node 端衔接</h6><p>那 Web 端构建的静态资源是如何 Node 服务端做衔接的呢?前端静态资源构建工作与 Node 服务相互分离,Node 服务在开启的过程中会读取前端构建生成的静态资源映射表。前端的构建过程如图 4 所示,在构建工作完成之后会生成<code>assets.json</code>静态资源映射表。</p>\n<figure>\n <img src=\"//solome.js.org/static/tech-salon-13-app-proto/static-file-map.svg\" onerror=\"if (!this.failed) {this.failed=1;this.src=this.src.replace(/\\.svg$/, '.png');}\" alt=\"pages-recipes\" />\n <figcaption>静态资源映射文件assets.json构建</figcaption>\n</figure>\n\n<blockquote>\n<p>前端构建工具基本都提供静态资源映射表生成插件,比如构建工具 Webpack 就存在插件<a href=\"https://github.com/kossnocorp/assets-webpack-plugin\">assets-webpack-plugin</a>来实现该功能。</p>\n</blockquote>\n<p>生成的<code>assets.json</code>映射表内容参考如下:</p>\n<figure class=\"highlight js\"><table><tr><td class=\"code\"><pre><code class=\"hljs js\">{<br> <span class=\"hljs-string\">"index"</span>: <span class=\"hljs-comment\">// 对应的页面(url: example.com/index)</span><br> { <span class=\"hljs-string\">"js"</span>:<span class=\"hljs-string\">"//s0.example.net/pms/index-2abb99.js"</span> }, <span class=\"hljs-comment\">// 涉及到的静态资源列表(带版本号)</span><br> <span class=\"hljs-string\">"login"</span>:<br> { <span class=\"hljs-string\">"js"</span>:<span class=\"hljs-string\">"//s0.example.net/pms/login-5917b0.js"</span> }<br>}<br></code></pre></td></tr></table></figure>\n\n<p>比如在渲染页面<code>example.com/index</code>时,Node 服务会以<code>index</code>作为键值,读取<code>assets.json</code>中带版本号的静态资源 CDN 地址列表,用于在"壳子"中与前端资源的衔接工作。</p>\n<h3 id=\"Web-端的一些-quot-约定-quot\"><a href=\"#Web-端的一些-quot-约定-quot\" class=\"headerlink\" title=\"Web 端的一些"约定"\"></a>Web 端的一些"约定"</h3><p>Web 端的技术选项是没有强制性限制的,无论你采用何种构建工具、前端库,只要生成符合约定供 Node 端使用的<code>assets.json</code>文件即可。</p>\n<p>前端工程师可以根据具体的业务特点、团队技术喜好来选取合理的开发方案,无论是 React、Vue 还是 Angular2 并不做强限制。尽管给予 Web 前端开发很大的自由度,但是鼓励遵循下面几条"约定":</p>\n<ul>\n<li>Ajax 请求从 Node 端代理,而非具体后端服务。</li>\n<li>鼓励将 JavaScript、CSS、HTML 视为前端领域的"汇编"。</li>\n<li>重视前端页面状态管理,推荐的方案有<a href=\"https://github.com/reactjs/redux\">Redux</a>、<a href=\"https://github.com/vuejs/vuex\">vuex</a>及<a href=\"https://github.com/mobxjs/mobx\">MobX</a>等。</li>\n<li>强调组件化,面向组件集开发。</li>\n</ul>\n<p>这里重点强调下面向组件集的前端开发。在项目初期我们一般不会马上投入到业务开发,而是针对设计师和产品经理提供的设计稿、产品原型图实现一套组件集或选择合适的开源组件集,积累好基础组件集后再投入到具体业务开发。</p>\n<p>在进行前端技术调研时,该技术是否有配套的开源组件集往往是我们考虑的重点。比如基于 React 实现的开源组件集<a href=\"http://ant.design/\">ant.design</a>、<a href=\"http://www.material-ui.com/\">Material-UI</a>等,我们部分前端项目都直接或间接的使用到了,极大地减少了研发成本。</p>\n<p>当然,美团点评内部也提供一个组件中心平台(可参考<a href=\"https://slides.com/solome/mt-components-hub/live#/\">美团点评前端组件中心介绍 Slide</a>),鼓励大家将各自项目中的有价值组件分享出来,实现组件跨项目复用。</p>\n<h4 id=\"工程化支持\"><a href=\"#工程化支持\" class=\"headerlink\" title=\"工程化支持\"></a>工程化支持</h4><h5 id=\"项目脚手架\"><a href=\"#项目脚手架\" class=\"headerlink\" title=\"项目脚手架\"></a>项目脚手架</h5><p>项目脚手架的作用是在启动一个新项目时,通过几个简单命令就能快速搭建好项目的开发环境。我们基于<a href=\"http://yeoman.io/\">Yeoman</a>构建了一个完整的项目脚手架。</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># 安装脚手架</span><br>$ npm install -g yo<br>$ npm install -g @ia/generator-app-proto@latest<br><span class=\"hljs-comment\"># 初始化新项目(进行简单选择)</span><br>$ yo @ia/app-proto<br></code></pre></td></tr></table></figure>\n\n<h5 id=\"工程质量保障\"><a href=\"#工程质量保障\" class=\"headerlink\" title=\"工程质量保障\"></a>工程质量保障</h5><p>我们重视项目的每次<code>commit</code>,同个项目要求遵循同一套编码规范,并采用<a href=\"http://eslint.org/\">ESLint</a>等工具进行约束,对于一些复用性高的核心组件也强制要求写测试。<br>为保障项目质量,每个项目都要求接入美团点评基于<a href=\"https://en.wikipedia.org/wiki/Stash_(software\">Stash</a>实现的Castle CI 系统,每次的源码提交都会自动执行一遍 ESLint、测试和构建,并生成构建日志通过公司内部沟通工具大象进行实时消息推送。</p>\n<h5 id=\"标准化测试环境管理\"><a href=\"#标准化测试环境管理\" class=\"headerlink\" title=\"标准化测试环境管理\"></a>标准化测试环境管理</h5><p>美团点评内部提供了基于 Docker 实现的测试环境管理服务 Cargo,用于提升测试和联调测试效率,促进 DevOps 开发模式。将项目接入到 Cargo 服务后,只需在仓库中提供简单的配置文件<code>cargo.yml</code>(配置参考如下),就会自动生成一套测试环境。</p>\n<figure class=\"highlight bash\"><table><tr><td class=\"code\"><pre><code class=\"hljs bash\"><span class=\"hljs-comment\"># 依赖的镜像</span><br>image: registry.cargo.example.com/node:v4.2.1<br><span class=\"hljs-comment\"># 容器占用的端口</span><br>ports:<br> - <span class=\"hljs-string\">'8998'</span><br><span class=\"hljs-comment\"># 环境变量</span><br>env:<br> - COMMON_VARIABLE = <span class=\"hljs-string\">'true'</span><br> - NODE_ENV = <span class=\"hljs-string\">'cargo'</span><br> - DEBUG = <span class=\"hljs-string\">'app-proto,datasource.*'</span><br><span class=\"hljs-comment\"># 收集的日志文件</span><br>logs:<br> - error = /var/path/logs/app-proto/error.log<br> - out = /var/path/logs/app-proto/out.log<br><span class=\"hljs-comment\"># 构建脚本</span><br>build_script: bin/pre-deploy-staging<br><span class=\"hljs-comment\"># 运行脚本</span><br>run_script: bin/cargo-start<br></code></pre></td></tr></table></figure>\n\n<h4 id=\"总结\"><a href=\"#总结\" class=\"headerlink\" title=\"总结\"></a>总结</h4><p>前端工程化体系的引入,让前端开发能和原生 App 应用项目开发一样"自成体系",脱离了对后端项目的依赖。基于"约定优于配置"、"按照约定写代码"的原则对 Node 层功能的设定能够降低沟通协调成本,构建、部署等工作的规范化,使前端技术人员的开发重点回归到 Web 应用的交互体验本身,回归到"纯粹"的前端研发。</p>\n"},{"layout":"layout/post","title":"那些年我关注过的NBA球星","date":"2019-11-15T19:00:00.000Z","comments":1,"_content":"\n> 我不喜欢用\"那些年\"这类很直白缅怀过去的字眼做标题,但是折腾许久才慢慢意识到现在的我已经找不着更好一点儿的词汇了。其实这就是一篇缅怀过去的一篇文章,并且是关于 NBA 球星的。因为我意识到自己已经很难和过去一样关注 NBA 赛事的输赢,NBA 于我渐行渐远。\n\n## 第一次接触\n\n首次接触 NBA 是在 2007 年,同学的一份体育类报纸被我当作隔离水泥的墙纸贴在宿舍的墙上。\n\n因为家距离学校不足一公里,我成为班级里极少数的\"走读生\"。但是学校还是给我预留了一个床位,或许嫌弃家附近的塑料工厂太吵,也可能是觉得晚自习十点后的夜太黑,亦或是思索着换种方式能与同学走得更近些,毕竟居住在一室共同的话题才多。总之,我在学校宿舍待了一段时间,每夜入睡前都会不经意间瞧见墙上的那图、那文字。\n\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/C13eRcgW4HnS8t9.jpg\" alt=\"科比・布莱恩特 vs. 拉沙德・刘易斯\" />\n <figcaption>科比・布莱恩特 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n报纸封面的新闻是关于拉沙德・刘易斯跟魔术队签了一份价值超亿的转会合同。虽然玩篮球有很长一段时间了,体验过扔进框的喜悦,享受过拼抢的激动;但那时我不知道世界上有 NBA 这类东西、这个联盟。我很好奇这个人有多厉害,一个运动员能值这么大价值?\n\n刚刚步入高中的我后来也发现,了解 NBA 资讯越多就能与同学有更多的共同话题,特别是体魄强壮的男生。往后周六日,我也愈来愈关注 CCTV5 频道、慢慢准时追着 NBA 球赛。\n\n球赛是无聊的,远没有自己在球场扔球来得好玩。追比赛的动力只为了上学第一天的周一能与同学有更多的谈资:谁赢了?哪个球星得了多少分?哪只队伍还有进季后赛的机会。\n\n球赛是无聊的,我坚持了一段时间就再也没接触 NBA 球赛了。因为懂得少,因为自己打篮球只是扔个球,因为比赛的输赢好像跟我无关。大家都喜欢科比、麦迪、詹姆斯、加内特,可是他们距离我好远。但是了解到身边存在着许多热衷篮球的同学,不论高矮、性别、成绩,喜欢的球队赢了关注的球员表现亮眼了都会很开心。\n\n球赛是无聊的,某个周六上午最后一节课刚刚结束,同桌迫不及待地悄悄跟我说\"阳儿,下午打篮球不?我借到了一个篮球下午可以来\"。果然,我居然没有注意到在他课桌的死角内放置着一个起着毛刺的篮球,我们课堂上时常喊着饥饿其实就是为了能够在操场上流汗。\n\n这种邀请我是不会拒绝的,从下午两点玩到六点,要不是快到饭点我们是不会离开的。\n同桌是真心爱篮球的,一直嫌弃自己一米七八的身高还不够高,因为喜欢加内特能够在一个夏天把自己晒得黝黑黝黑的。\n于我而言,这比看篮球比赛有意思多了,挣着球一群人流着汗...\n\n## 中国队\n\n08 年是中国多灾多难的一年,也是我存储许多记忆的一年。 \n年初的南方大雪,寒假在家的我担心父亲还能不能回家过年,给我准备新衣裳、发压岁钱。 \n刚开学不久,新闻里播着西藏拉萨打砸抢烧暴力事件,文科老师偶尔也在课堂上义愤填膺。\n\n当我紧张忙着午间休憩时,又听闻四川汶川地震。幸好在四川的外婆没啥事。趁着暑假,和妹妹二人勇敢地去了一趟四川德阳。经历过几次余震,却无意识畏惧,日常就是和妹妹在德阳街道图书馆看书,在家追着动漫,对川式水煮五花肉也终身难忘,刺激味蕾的记忆是挥之不去的。\n\n<figure>\n <img style=\"width: 80%\" src=\"//i.loli.net/2019/11/17/RcA9CdtbB7igSVO.jpg\" alt=\"北京奥运\" />\n <figcaption>2008・北京奥运</figcaption>\n</figure>\n\n最重要的还是北京奥运啦,\"北京欢迎你\"的调调随处都存在着。\n\n那个阶段我关注的是刘翔的 110 米栏、中国足球、中国篮球。等待了一上午,刘翔没有完成比赛,心中难以释怀——或许**1356**就不应该押付到一个人的身上。\n足球是对阵美丽遥远的新西兰,拿下奥运首个进球的中国队没有拿下比赛;中国队的实力怎么这么不济,这是我难以接受的事实。还好,篮球比赛是很精彩的。\n\n印象最深刻的是中国对战美国和中国对战西班牙——对战世界第一、第二。\n第一例进球是姚明的三分,赛前我构思了第一个球的各种场景,却没猜测到居然会是姚明的三分。\n朱芳雨、王仕鹏的手感好好,各种进;看着矮矮的刘炜,运起球来各种潇洒;易建联、王治郅、孙悦与美国全明星对位起来一点儿也不虚。\n双方有来有回不可开交,虽然半场之后中国队慢慢落入下风。\n确实我们都知道这是一场结果赤裸裸的比赛,但还是为中国队的球员骄傲、发自内心的。更何况,对阵西班牙的比赛中国队差点还赢了。\n\n原来篮球这么好看,哦不原来打篮球的那一群人是如此优秀。\n\n## 季后赛\n\n学业的压力徒增,生活中面临的诱惑也多了许多,流汗的疯狂却越来越少;不过,看 NBA 球赛却成为学习之外的日常。\n\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/PyYmGBsi982LE6v.jpg\" alt=\"季后赛姚明迎接湖人队防守\" />\n <figcaption>季后赛姚明迎接湖人队防守</figcaption>\n</figure>\n\n〇九上半年,班级中最具人气的球员麦迪被交易走了,没有麦迪的火箭队其实更强大。季后赛与湖人挣抢七让我见识到竞技体育的韧性,记忆最深刻的是那一场比赛好多同学凌晨就逃离校园去看比赛了,期待奇迹却没有奇迹。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//i.loli.net/2019/11/17/58xRCczaVfjXKJg.jpg\" alt=\"勒布朗・詹姆斯 vs. 拉沙德・刘易斯\" />\n <figcaption>勒布朗・詹姆斯 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n另外一边则是詹姆斯的骑士和魔术鏖战着,我知道有许多人不喜欢詹姆斯,但是看过那一轮系列赛的我对他的评价始终是无与伦比的优秀。有第二场绝杀转身举指的怒吼,也有第六场的黯然离去,上演着我以为只有电影里面才有的剧情。\n\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/y4HSirBYb6J5xmw.jpg\" alt=\"季后赛科比:湖人 vs. 魔术\" />\n <figcaption>季后赛科比:湖人 vs. 魔术</figcaption>\n</figure>\n\n〇九总决赛的主角是科比,第一场科比求胜的眼神告诉我\"第二名才是真正的输家\"不是简单说说而已。熬过巴蒂尔和阿泰斯特防守,那个时期的联盟还有什么组合能够阻止科比夺冠的心。第二年依旧是科比与凯尔特人大战七场,科比上演了自己曼巴式复仇,不过看着他们这群球员比赛真的好可爱。\n\n一一年是是神奇的逆转,当小牛夺冠诺维茨基第一时间离开球场;拉起球衣捂住脸掩盖自己失控的情绪。〇六年总决赛被翻盘,〇七年被勇士\"黑八\",一〇年又被马刺\"黑七\",没有被打入谷底的耻辱,怎会有攀上顶峰的泪水。\n\n一二年在热火的詹姆斯终于拿到自己想要的荣誉,虽然我不认同\"无冠之王不是王\"的价值观。\n\n一三年,雷・阿伦的神奇三分与吉诺比利神一般的表现以及邓肯最后一场最后一刻不甘地捶地板。马刺太老了、GDP 时代结束了 …\n\n一四年,去年马刺的失利让我耿耿于怀了许久,想不到今年能够卷土重来,我好害怕马刺再输。但总决赛阵容与去年一般,我告诉我自己这个系列赛我一场都不愿意错过。\n可惜,最后一次比赛却发生在我毕业答辩时刻...\n\n<figure>\n <img src=\"//i.loli.net/2019/11/17/cbgDJx8uoqjPK7i.jpg\" alt=\"马刺夺冠\" />\n <figcaption>马刺夺冠:\"每一次的失败都是下一次的卷土重来\"</figcaption>\n</figure>\n\n马刺夺冠时刻就是我毕业答辩的时刻。总决赛的最后一场,我在答辩室盯着 3G 网络手机中的文字直播,可是答辩的次序被安排在了上午。我的内心是紊乱的,毕业设计做得不够好,害怕被老师刁难,又担心马刺不能赢。想不到大学的最后时刻是如此纠结的体验,庆幸结局都是美好的。\n\n## 最后\n\n未来很长的一段时间我可能会很难再关注 NBA 联赛,吸引我的是某些球员,让我不舍的是那碎片一样的岁月,而非 NBA 联赛,无关篮球。追求自己想要、喜欢的路途感觉真好,虽然会一路波折、忐忑、耻辱。\n\n> 版权声明:文中涉及图片素材均来源于[Zimbio](//www.zimbio.com),版权归属于[Zimbio](//www.zimbio.com)。\n","source":"_posts/those-years-nba-player.md","raw":"---\nlayout: layout/post\ntitle: '那些年我关注过的NBA球星'\ndate: 2019-11-16 03:00:00 +0800\ncomments: true\ncategories: 日常碎碎唸\n---\n\n> 我不喜欢用\"那些年\"这类很直白缅怀过去的字眼做标题,但是折腾许久才慢慢意识到现在的我已经找不着更好一点儿的词汇了。其实这就是一篇缅怀过去的一篇文章,并且是关于 NBA 球星的。因为我意识到自己已经很难和过去一样关注 NBA 赛事的输赢,NBA 于我渐行渐远。\n\n## 第一次接触\n\n首次接触 NBA 是在 2007 年,同学的一份体育类报纸被我当作隔离水泥的墙纸贴在宿舍的墙上。\n\n因为家距离学校不足一公里,我成为班级里极少数的\"走读生\"。但是学校还是给我预留了一个床位,或许嫌弃家附近的塑料工厂太吵,也可能是觉得晚自习十点后的夜太黑,亦或是思索着换种方式能与同学走得更近些,毕竟居住在一室共同的话题才多。总之,我在学校宿舍待了一段时间,每夜入睡前都会不经意间瞧见墙上的那图、那文字。\n\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/C13eRcgW4HnS8t9.jpg\" alt=\"科比・布莱恩特 vs. 拉沙德・刘易斯\" />\n <figcaption>科比・布莱恩特 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n报纸封面的新闻是关于拉沙德・刘易斯跟魔术队签了一份价值超亿的转会合同。虽然玩篮球有很长一段时间了,体验过扔进框的喜悦,享受过拼抢的激动;但那时我不知道世界上有 NBA 这类东西、这个联盟。我很好奇这个人有多厉害,一个运动员能值这么大价值?\n\n刚刚步入高中的我后来也发现,了解 NBA 资讯越多就能与同学有更多的共同话题,特别是体魄强壮的男生。往后周六日,我也愈来愈关注 CCTV5 频道、慢慢准时追着 NBA 球赛。\n\n球赛是无聊的,远没有自己在球场扔球来得好玩。追比赛的动力只为了上学第一天的周一能与同学有更多的谈资:谁赢了?哪个球星得了多少分?哪只队伍还有进季后赛的机会。\n\n球赛是无聊的,我坚持了一段时间就再也没接触 NBA 球赛了。因为懂得少,因为自己打篮球只是扔个球,因为比赛的输赢好像跟我无关。大家都喜欢科比、麦迪、詹姆斯、加内特,可是他们距离我好远。但是了解到身边存在着许多热衷篮球的同学,不论高矮、性别、成绩,喜欢的球队赢了关注的球员表现亮眼了都会很开心。\n\n球赛是无聊的,某个周六上午最后一节课刚刚结束,同桌迫不及待地悄悄跟我说\"阳儿,下午打篮球不?我借到了一个篮球下午可以来\"。果然,我居然没有注意到在他课桌的死角内放置着一个起着毛刺的篮球,我们课堂上时常喊着饥饿其实就是为了能够在操场上流汗。\n\n这种邀请我是不会拒绝的,从下午两点玩到六点,要不是快到饭点我们是不会离开的。\n同桌是真心爱篮球的,一直嫌弃自己一米七八的身高还不够高,因为喜欢加内特能够在一个夏天把自己晒得黝黑黝黑的。\n于我而言,这比看篮球比赛有意思多了,挣着球一群人流着汗...\n\n## 中国队\n\n08 年是中国多灾多难的一年,也是我存储许多记忆的一年。 \n年初的南方大雪,寒假在家的我担心父亲还能不能回家过年,给我准备新衣裳、发压岁钱。 \n刚开学不久,新闻里播着西藏拉萨打砸抢烧暴力事件,文科老师偶尔也在课堂上义愤填膺。\n\n当我紧张忙着午间休憩时,又听闻四川汶川地震。幸好在四川的外婆没啥事。趁着暑假,和妹妹二人勇敢地去了一趟四川德阳。经历过几次余震,却无意识畏惧,日常就是和妹妹在德阳街道图书馆看书,在家追着动漫,对川式水煮五花肉也终身难忘,刺激味蕾的记忆是挥之不去的。\n\n<figure>\n <img style=\"width: 80%\" src=\"//i.loli.net/2019/11/17/RcA9CdtbB7igSVO.jpg\" alt=\"北京奥运\" />\n <figcaption>2008・北京奥运</figcaption>\n</figure>\n\n最重要的还是北京奥运啦,\"北京欢迎你\"的调调随处都存在着。\n\n那个阶段我关注的是刘翔的 110 米栏、中国足球、中国篮球。等待了一上午,刘翔没有完成比赛,心中难以释怀——或许**1356**就不应该押付到一个人的身上。\n足球是对阵美丽遥远的新西兰,拿下奥运首个进球的中国队没有拿下比赛;中国队的实力怎么这么不济,这是我难以接受的事实。还好,篮球比赛是很精彩的。\n\n印象最深刻的是中国对战美国和中国对战西班牙——对战世界第一、第二。\n第一例进球是姚明的三分,赛前我构思了第一个球的各种场景,却没猜测到居然会是姚明的三分。\n朱芳雨、王仕鹏的手感好好,各种进;看着矮矮的刘炜,运起球来各种潇洒;易建联、王治郅、孙悦与美国全明星对位起来一点儿也不虚。\n双方有来有回不可开交,虽然半场之后中国队慢慢落入下风。\n确实我们都知道这是一场结果赤裸裸的比赛,但还是为中国队的球员骄傲、发自内心的。更何况,对阵西班牙的比赛中国队差点还赢了。\n\n原来篮球这么好看,哦不原来打篮球的那一群人是如此优秀。\n\n## 季后赛\n\n学业的压力徒增,生活中面临的诱惑也多了许多,流汗的疯狂却越来越少;不过,看 NBA 球赛却成为学习之外的日常。\n\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/PyYmGBsi982LE6v.jpg\" alt=\"季后赛姚明迎接湖人队防守\" />\n <figcaption>季后赛姚明迎接湖人队防守</figcaption>\n</figure>\n\n〇九上半年,班级中最具人气的球员麦迪被交易走了,没有麦迪的火箭队其实更强大。季后赛与湖人挣抢七让我见识到竞技体育的韧性,记忆最深刻的是那一场比赛好多同学凌晨就逃离校园去看比赛了,期待奇迹却没有奇迹。\n\n<figure>\n <img style=\"width: 50%;\" src=\"//i.loli.net/2019/11/17/58xRCczaVfjXKJg.jpg\" alt=\"勒布朗・詹姆斯 vs. 拉沙德・刘易斯\" />\n <figcaption>勒布朗・詹姆斯 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n另外一边则是詹姆斯的骑士和魔术鏖战着,我知道有许多人不喜欢詹姆斯,但是看过那一轮系列赛的我对他的评价始终是无与伦比的优秀。有第二场绝杀转身举指的怒吼,也有第六场的黯然离去,上演着我以为只有电影里面才有的剧情。\n\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/y4HSirBYb6J5xmw.jpg\" alt=\"季后赛科比:湖人 vs. 魔术\" />\n <figcaption>季后赛科比:湖人 vs. 魔术</figcaption>\n</figure>\n\n〇九总决赛的主角是科比,第一场科比求胜的眼神告诉我\"第二名才是真正的输家\"不是简单说说而已。熬过巴蒂尔和阿泰斯特防守,那个时期的联盟还有什么组合能够阻止科比夺冠的心。第二年依旧是科比与凯尔特人大战七场,科比上演了自己曼巴式复仇,不过看着他们这群球员比赛真的好可爱。\n\n一一年是是神奇的逆转,当小牛夺冠诺维茨基第一时间离开球场;拉起球衣捂住脸掩盖自己失控的情绪。〇六年总决赛被翻盘,〇七年被勇士\"黑八\",一〇年又被马刺\"黑七\",没有被打入谷底的耻辱,怎会有攀上顶峰的泪水。\n\n一二年在热火的詹姆斯终于拿到自己想要的荣誉,虽然我不认同\"无冠之王不是王\"的价值观。\n\n一三年,雷・阿伦的神奇三分与吉诺比利神一般的表现以及邓肯最后一场最后一刻不甘地捶地板。马刺太老了、GDP 时代结束了 …\n\n一四年,去年马刺的失利让我耿耿于怀了许久,想不到今年能够卷土重来,我好害怕马刺再输。但总决赛阵容与去年一般,我告诉我自己这个系列赛我一场都不愿意错过。\n可惜,最后一次比赛却发生在我毕业答辩时刻...\n\n<figure>\n <img src=\"//i.loli.net/2019/11/17/cbgDJx8uoqjPK7i.jpg\" alt=\"马刺夺冠\" />\n <figcaption>马刺夺冠:\"每一次的失败都是下一次的卷土重来\"</figcaption>\n</figure>\n\n马刺夺冠时刻就是我毕业答辩的时刻。总决赛的最后一场,我在答辩室盯着 3G 网络手机中的文字直播,可是答辩的次序被安排在了上午。我的内心是紊乱的,毕业设计做得不够好,害怕被老师刁难,又担心马刺不能赢。想不到大学的最后时刻是如此纠结的体验,庆幸结局都是美好的。\n\n## 最后\n\n未来很长的一段时间我可能会很难再关注 NBA 联赛,吸引我的是某些球员,让我不舍的是那碎片一样的岁月,而非 NBA 联赛,无关篮球。追求自己想要、喜欢的路途感觉真好,虽然会一路波折、忐忑、耻辱。\n\n> 版权声明:文中涉及图片素材均来源于[Zimbio](//www.zimbio.com),版权归属于[Zimbio](//www.zimbio.com)。\n","slug":"those-years-nba-player","published":1,"updated":"2023-11-11T14:25:01.007Z","_id":"clooia2m5000nln3yhnpb8l6s","photos":[],"link":"","content":"<blockquote>\n<p>我不喜欢用"那些年"这类很直白缅怀过去的字眼做标题,但是折腾许久才慢慢意识到现在的我已经找不着更好一点儿的词汇了。其实这就是一篇缅怀过去的一篇文章,并且是关于 NBA 球星的。因为我意识到自己已经很难和过去一样关注 NBA 赛事的输赢,NBA 于我渐行渐远。</p>\n</blockquote>\n<h2 id=\"第一次接触\"><a href=\"#第一次接触\" class=\"headerlink\" title=\"第一次接触\"></a>第一次接触</h2><p>首次接触 NBA 是在 2007 年,同学的一份体育类报纸被我当作隔离水泥的墙纸贴在宿舍的墙上。</p>\n<p>因为家距离学校不足一公里,我成为班级里极少数的"走读生"。但是学校还是给我预留了一个床位,或许嫌弃家附近的塑料工厂太吵,也可能是觉得晚自习十点后的夜太黑,亦或是思索着换种方式能与同学走得更近些,毕竟居住在一室共同的话题才多。总之,我在学校宿舍待了一段时间,每夜入睡前都会不经意间瞧见墙上的那图、那文字。</p>\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/C13eRcgW4HnS8t9.jpg\" alt=\"科比・布莱恩特 vs. 拉沙德・刘易斯\" />\n <figcaption>科比・布莱恩特 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n<p>报纸封面的新闻是关于拉沙德・刘易斯跟魔术队签了一份价值超亿的转会合同。虽然玩篮球有很长一段时间了,体验过扔进框的喜悦,享受过拼抢的激动;但那时我不知道世界上有 NBA 这类东西、这个联盟。我很好奇这个人有多厉害,一个运动员能值这么大价值?</p>\n<p>刚刚步入高中的我后来也发现,了解 NBA 资讯越多就能与同学有更多的共同话题,特别是体魄强壮的男生。往后周六日,我也愈来愈关注 CCTV5 频道、慢慢准时追着 NBA 球赛。</p>\n<p>球赛是无聊的,远没有自己在球场扔球来得好玩。追比赛的动力只为了上学第一天的周一能与同学有更多的谈资:谁赢了?哪个球星得了多少分?哪只队伍还有进季后赛的机会。</p>\n<p>球赛是无聊的,我坚持了一段时间就再也没接触 NBA 球赛了。因为懂得少,因为自己打篮球只是扔个球,因为比赛的输赢好像跟我无关。大家都喜欢科比、麦迪、詹姆斯、加内特,可是他们距离我好远。但是了解到身边存在着许多热衷篮球的同学,不论高矮、性别、成绩,喜欢的球队赢了关注的球员表现亮眼了都会很开心。</p>\n<p>球赛是无聊的,某个周六上午最后一节课刚刚结束,同桌迫不及待地悄悄跟我说"阳儿,下午打篮球不?我借到了一个篮球下午可以来"。果然,我居然没有注意到在他课桌的死角内放置着一个起着毛刺的篮球,我们课堂上时常喊着饥饿其实就是为了能够在操场上流汗。</p>\n<p>这种邀请我是不会拒绝的,从下午两点玩到六点,要不是快到饭点我们是不会离开的。<br>同桌是真心爱篮球的,一直嫌弃自己一米七八的身高还不够高,因为喜欢加内特能够在一个夏天把自己晒得黝黑黝黑的。<br>于我而言,这比看篮球比赛有意思多了,挣着球一群人流着汗...</p>\n<h2 id=\"中国队\"><a href=\"#中国队\" class=\"headerlink\" title=\"中国队\"></a>中国队</h2><p>08 年是中国多灾多难的一年,也是我存储许多记忆的一年。<br>年初的南方大雪,寒假在家的我担心父亲还能不能回家过年,给我准备新衣裳、发压岁钱。<br>刚开学不久,新闻里播着西藏拉萨打砸抢烧暴力事件,文科老师偶尔也在课堂上义愤填膺。</p>\n<p>当我紧张忙着午间休憩时,又听闻四川汶川地震。幸好在四川的外婆没啥事。趁着暑假,和妹妹二人勇敢地去了一趟四川德阳。经历过几次余震,却无意识畏惧,日常就是和妹妹在德阳街道图书馆看书,在家追着动漫,对川式水煮五花肉也终身难忘,刺激味蕾的记忆是挥之不去的。</p>\n<figure>\n <img style=\"width: 80%\" src=\"//i.loli.net/2019/11/17/RcA9CdtbB7igSVO.jpg\" alt=\"北京奥运\" />\n <figcaption>2008・北京奥运</figcaption>\n</figure>\n\n<p>最重要的还是北京奥运啦,"北京欢迎你"的调调随处都存在着。</p>\n<p>那个阶段我关注的是刘翔的 110 米栏、中国足球、中国篮球。等待了一上午,刘翔没有完成比赛,心中难以释怀——或许<strong>1356</strong>就不应该押付到一个人的身上。<br>足球是对阵美丽遥远的新西兰,拿下奥运首个进球的中国队没有拿下比赛;中国队的实力怎么这么不济,这是我难以接受的事实。还好,篮球比赛是很精彩的。</p>\n<p>印象最深刻的是中国对战美国和中国对战西班牙——对战世界第一、第二。<br>第一例进球是姚明的三分,赛前我构思了第一个球的各种场景,却没猜测到居然会是姚明的三分。<br>朱芳雨、王仕鹏的手感好好,各种进;看着矮矮的刘炜,运起球来各种潇洒;易建联、王治郅、孙悦与美国全明星对位起来一点儿也不虚。<br>双方有来有回不可开交,虽然半场之后中国队慢慢落入下风。<br>确实我们都知道这是一场结果赤裸裸的比赛,但还是为中国队的球员骄傲、发自内心的。更何况,对阵西班牙的比赛中国队差点还赢了。</p>\n<p>原来篮球这么好看,哦不原来打篮球的那一群人是如此优秀。</p>\n<h2 id=\"季后赛\"><a href=\"#季后赛\" class=\"headerlink\" title=\"季后赛\"></a>季后赛</h2><p>学业的压力徒增,生活中面临的诱惑也多了许多,流汗的疯狂却越来越少;不过,看 NBA 球赛却成为学习之外的日常。</p>\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/PyYmGBsi982LE6v.jpg\" alt=\"季后赛姚明迎接湖人队防守\" />\n <figcaption>季后赛姚明迎接湖人队防守</figcaption>\n</figure>\n\n<p>〇九上半年,班级中最具人气的球员麦迪被交易走了,没有麦迪的火箭队其实更强大。季后赛与湖人挣抢七让我见识到竞技体育的韧性,记忆最深刻的是那一场比赛好多同学凌晨就逃离校园去看比赛了,期待奇迹却没有奇迹。</p>\n<figure>\n <img style=\"width: 50%;\" src=\"//i.loli.net/2019/11/17/58xRCczaVfjXKJg.jpg\" alt=\"勒布朗・詹姆斯 vs. 拉沙德・刘易斯\" />\n <figcaption>勒布朗・詹姆斯 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n<p>另外一边则是詹姆斯的骑士和魔术鏖战着,我知道有许多人不喜欢詹姆斯,但是看过那一轮系列赛的我对他的评价始终是无与伦比的优秀。有第二场绝杀转身举指的怒吼,也有第六场的黯然离去,上演着我以为只有电影里面才有的剧情。</p>\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/y4HSirBYb6J5xmw.jpg\" alt=\"季后赛科比:湖人 vs. 魔术\" />\n <figcaption>季后赛科比:湖人 vs. 魔术</figcaption>\n</figure>\n\n<p>〇九总决赛的主角是科比,第一场科比求胜的眼神告诉我"第二名才是真正的输家"不是简单说说而已。熬过巴蒂尔和阿泰斯特防守,那个时期的联盟还有什么组合能够阻止科比夺冠的心。第二年依旧是科比与凯尔特人大战七场,科比上演了自己曼巴式复仇,不过看着他们这群球员比赛真的好可爱。</p>\n<p>一一年是是神奇的逆转,当小牛夺冠诺维茨基第一时间离开球场;拉起球衣捂住脸掩盖自己失控的情绪。〇六年总决赛被翻盘,〇七年被勇士"黑八",一〇年又被马刺"黑七",没有被打入谷底的耻辱,怎会有攀上顶峰的泪水。</p>\n<p>一二年在热火的詹姆斯终于拿到自己想要的荣誉,虽然我不认同"无冠之王不是王"的价值观。</p>\n<p>一三年,雷・阿伦的神奇三分与吉诺比利神一般的表现以及邓肯最后一场最后一刻不甘地捶地板。马刺太老了、GDP 时代结束了 …</p>\n<p>一四年,去年马刺的失利让我耿耿于怀了许久,想不到今年能够卷土重来,我好害怕马刺再输。但总决赛阵容与去年一般,我告诉我自己这个系列赛我一场都不愿意错过。<br>可惜,最后一次比赛却发生在我毕业答辩时刻...</p>\n<figure>\n <img src=\"//i.loli.net/2019/11/17/cbgDJx8uoqjPK7i.jpg\" alt=\"马刺夺冠\" />\n <figcaption>马刺夺冠:\"每一次的失败都是下一次的卷土重来\"</figcaption>\n</figure>\n\n<p>马刺夺冠时刻就是我毕业答辩的时刻。总决赛的最后一场,我在答辩室盯着 3G 网络手机中的文字直播,可是答辩的次序被安排在了上午。我的内心是紊乱的,毕业设计做得不够好,害怕被老师刁难,又担心马刺不能赢。想不到大学的最后时刻是如此纠结的体验,庆幸结局都是美好的。</p>\n<h2 id=\"最后\"><a href=\"#最后\" class=\"headerlink\" title=\"最后\"></a>最后</h2><p>未来很长的一段时间我可能会很难再关注 NBA 联赛,吸引我的是某些球员,让我不舍的是那碎片一样的岁月,而非 NBA 联赛,无关篮球。追求自己想要、喜欢的路途感觉真好,虽然会一路波折、忐忑、耻辱。</p>\n<blockquote>\n<p>版权声明:文中涉及图片素材均来源于<a href=\"//www.zimbio.com\">Zimbio</a>,版权归属于<a href=\"//www.zimbio.com\">Zimbio</a>。</p>\n</blockquote>\n","site":{"data":{}},"excerpt":"","more":"<blockquote>\n<p>我不喜欢用"那些年"这类很直白缅怀过去的字眼做标题,但是折腾许久才慢慢意识到现在的我已经找不着更好一点儿的词汇了。其实这就是一篇缅怀过去的一篇文章,并且是关于 NBA 球星的。因为我意识到自己已经很难和过去一样关注 NBA 赛事的输赢,NBA 于我渐行渐远。</p>\n</blockquote>\n<h2 id=\"第一次接触\"><a href=\"#第一次接触\" class=\"headerlink\" title=\"第一次接触\"></a>第一次接触</h2><p>首次接触 NBA 是在 2007 年,同学的一份体育类报纸被我当作隔离水泥的墙纸贴在宿舍的墙上。</p>\n<p>因为家距离学校不足一公里,我成为班级里极少数的"走读生"。但是学校还是给我预留了一个床位,或许嫌弃家附近的塑料工厂太吵,也可能是觉得晚自习十点后的夜太黑,亦或是思索着换种方式能与同学走得更近些,毕竟居住在一室共同的话题才多。总之,我在学校宿舍待了一段时间,每夜入睡前都会不经意间瞧见墙上的那图、那文字。</p>\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/C13eRcgW4HnS8t9.jpg\" alt=\"科比・布莱恩特 vs. 拉沙德・刘易斯\" />\n <figcaption>科比・布莱恩特 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n<p>报纸封面的新闻是关于拉沙德・刘易斯跟魔术队签了一份价值超亿的转会合同。虽然玩篮球有很长一段时间了,体验过扔进框的喜悦,享受过拼抢的激动;但那时我不知道世界上有 NBA 这类东西、这个联盟。我很好奇这个人有多厉害,一个运动员能值这么大价值?</p>\n<p>刚刚步入高中的我后来也发现,了解 NBA 资讯越多就能与同学有更多的共同话题,特别是体魄强壮的男生。往后周六日,我也愈来愈关注 CCTV5 频道、慢慢准时追着 NBA 球赛。</p>\n<p>球赛是无聊的,远没有自己在球场扔球来得好玩。追比赛的动力只为了上学第一天的周一能与同学有更多的谈资:谁赢了?哪个球星得了多少分?哪只队伍还有进季后赛的机会。</p>\n<p>球赛是无聊的,我坚持了一段时间就再也没接触 NBA 球赛了。因为懂得少,因为自己打篮球只是扔个球,因为比赛的输赢好像跟我无关。大家都喜欢科比、麦迪、詹姆斯、加内特,可是他们距离我好远。但是了解到身边存在着许多热衷篮球的同学,不论高矮、性别、成绩,喜欢的球队赢了关注的球员表现亮眼了都会很开心。</p>\n<p>球赛是无聊的,某个周六上午最后一节课刚刚结束,同桌迫不及待地悄悄跟我说"阳儿,下午打篮球不?我借到了一个篮球下午可以来"。果然,我居然没有注意到在他课桌的死角内放置着一个起着毛刺的篮球,我们课堂上时常喊着饥饿其实就是为了能够在操场上流汗。</p>\n<p>这种邀请我是不会拒绝的,从下午两点玩到六点,要不是快到饭点我们是不会离开的。<br>同桌是真心爱篮球的,一直嫌弃自己一米七八的身高还不够高,因为喜欢加内特能够在一个夏天把自己晒得黝黑黝黑的。<br>于我而言,这比看篮球比赛有意思多了,挣着球一群人流着汗...</p>\n<h2 id=\"中国队\"><a href=\"#中国队\" class=\"headerlink\" title=\"中国队\"></a>中国队</h2><p>08 年是中国多灾多难的一年,也是我存储许多记忆的一年。<br>年初的南方大雪,寒假在家的我担心父亲还能不能回家过年,给我准备新衣裳、发压岁钱。<br>刚开学不久,新闻里播着西藏拉萨打砸抢烧暴力事件,文科老师偶尔也在课堂上义愤填膺。</p>\n<p>当我紧张忙着午间休憩时,又听闻四川汶川地震。幸好在四川的外婆没啥事。趁着暑假,和妹妹二人勇敢地去了一趟四川德阳。经历过几次余震,却无意识畏惧,日常就是和妹妹在德阳街道图书馆看书,在家追着动漫,对川式水煮五花肉也终身难忘,刺激味蕾的记忆是挥之不去的。</p>\n<figure>\n <img style=\"width: 80%\" src=\"//i.loli.net/2019/11/17/RcA9CdtbB7igSVO.jpg\" alt=\"北京奥运\" />\n <figcaption>2008・北京奥运</figcaption>\n</figure>\n\n<p>最重要的还是北京奥运啦,"北京欢迎你"的调调随处都存在着。</p>\n<p>那个阶段我关注的是刘翔的 110 米栏、中国足球、中国篮球。等待了一上午,刘翔没有完成比赛,心中难以释怀——或许<strong>1356</strong>就不应该押付到一个人的身上。<br>足球是对阵美丽遥远的新西兰,拿下奥运首个进球的中国队没有拿下比赛;中国队的实力怎么这么不济,这是我难以接受的事实。还好,篮球比赛是很精彩的。</p>\n<p>印象最深刻的是中国对战美国和中国对战西班牙——对战世界第一、第二。<br>第一例进球是姚明的三分,赛前我构思了第一个球的各种场景,却没猜测到居然会是姚明的三分。<br>朱芳雨、王仕鹏的手感好好,各种进;看着矮矮的刘炜,运起球来各种潇洒;易建联、王治郅、孙悦与美国全明星对位起来一点儿也不虚。<br>双方有来有回不可开交,虽然半场之后中国队慢慢落入下风。<br>确实我们都知道这是一场结果赤裸裸的比赛,但还是为中国队的球员骄傲、发自内心的。更何况,对阵西班牙的比赛中国队差点还赢了。</p>\n<p>原来篮球这么好看,哦不原来打篮球的那一群人是如此优秀。</p>\n<h2 id=\"季后赛\"><a href=\"#季后赛\" class=\"headerlink\" title=\"季后赛\"></a>季后赛</h2><p>学业的压力徒增,生活中面临的诱惑也多了许多,流汗的疯狂却越来越少;不过,看 NBA 球赛却成为学习之外的日常。</p>\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/PyYmGBsi982LE6v.jpg\" alt=\"季后赛姚明迎接湖人队防守\" />\n <figcaption>季后赛姚明迎接湖人队防守</figcaption>\n</figure>\n\n<p>〇九上半年,班级中最具人气的球员麦迪被交易走了,没有麦迪的火箭队其实更强大。季后赛与湖人挣抢七让我见识到竞技体育的韧性,记忆最深刻的是那一场比赛好多同学凌晨就逃离校园去看比赛了,期待奇迹却没有奇迹。</p>\n<figure>\n <img style=\"width: 50%;\" src=\"//i.loli.net/2019/11/17/58xRCczaVfjXKJg.jpg\" alt=\"勒布朗・詹姆斯 vs. 拉沙德・刘易斯\" />\n <figcaption>勒布朗・詹姆斯 vs. 拉沙德・刘易斯</figcaption>\n</figure>\n\n<p>另外一边则是詹姆斯的骑士和魔术鏖战着,我知道有许多人不喜欢詹姆斯,但是看过那一轮系列赛的我对他的评价始终是无与伦比的优秀。有第二场绝杀转身举指的怒吼,也有第六场的黯然离去,上演着我以为只有电影里面才有的剧情。</p>\n<figure>\n <img style=\"width: 60%\" src=\"//i.loli.net/2019/11/17/y4HSirBYb6J5xmw.jpg\" alt=\"季后赛科比:湖人 vs. 魔术\" />\n <figcaption>季后赛科比:湖人 vs. 魔术</figcaption>\n</figure>\n\n<p>〇九总决赛的主角是科比,第一场科比求胜的眼神告诉我"第二名才是真正的输家"不是简单说说而已。熬过巴蒂尔和阿泰斯特防守,那个时期的联盟还有什么组合能够阻止科比夺冠的心。第二年依旧是科比与凯尔特人大战七场,科比上演了自己曼巴式复仇,不过看着他们这群球员比赛真的好可爱。</p>\n<p>一一年是是神奇的逆转,当小牛夺冠诺维茨基第一时间离开球场;拉起球衣捂住脸掩盖自己失控的情绪。〇六年总决赛被翻盘,〇七年被勇士"黑八",一〇年又被马刺"黑七",没有被打入谷底的耻辱,怎会有攀上顶峰的泪水。</p>\n<p>一二年在热火的詹姆斯终于拿到自己想要的荣誉,虽然我不认同"无冠之王不是王"的价值观。</p>\n<p>一三年,雷・阿伦的神奇三分与吉诺比利神一般的表现以及邓肯最后一场最后一刻不甘地捶地板。马刺太老了、GDP 时代结束了 …</p>\n<p>一四年,去年马刺的失利让我耿耿于怀了许久,想不到今年能够卷土重来,我好害怕马刺再输。但总决赛阵容与去年一般,我告诉我自己这个系列赛我一场都不愿意错过。<br>可惜,最后一次比赛却发生在我毕业答辩时刻...</p>\n<figure>\n <img src=\"//i.loli.net/2019/11/17/cbgDJx8uoqjPK7i.jpg\" alt=\"马刺夺冠\" />\n <figcaption>马刺夺冠:\"每一次的失败都是下一次的卷土重来\"</figcaption>\n</figure>\n\n<p>马刺夺冠时刻就是我毕业答辩的时刻。总决赛的最后一场,我在答辩室盯着 3G 网络手机中的文字直播,可是答辩的次序被安排在了上午。我的内心是紊乱的,毕业设计做得不够好,害怕被老师刁难,又担心马刺不能赢。想不到大学的最后时刻是如此纠结的体验,庆幸结局都是美好的。</p>\n<h2 id=\"最后\"><a href=\"#最后\" class=\"headerlink\" title=\"最后\"></a>最后</h2><p>未来很长的一段时间我可能会很难再关注 NBA 联赛,吸引我的是某些球员,让我不舍的是那碎片一样的岁月,而非 NBA 联赛,无关篮球。追求自己想要、喜欢的路途感觉真好,虽然会一路波折、忐忑、耻辱。</p>\n<blockquote>\n<p>版权声明:文中涉及图片素材均来源于<a href=\"//www.zimbio.com\">Zimbio</a>,版权归属于<a href=\"//www.zimbio.com\">Zimbio</a>。</p>\n</blockquote>\n"},{"title":"北京漫游:大兴野生动物园","date":"2023-03-19T15:30:00.000Z","comments":1,"_content":"\n### 缘起\n\n最近恋上[小红书](https://www.xiaohongshu.com/explore)(很难想象我会成为**小红书**的重度用户)——也就是在某天晚上在小红书上刷到了大兴野生动物园里面关于小熊猫的内容——天啊~为什么地球上有这么可爱的小动物!\n\n我想去现场瞧一瞧。但曾经逛西安海洋馆的悲伤记忆还在,我实在接受不了关在笼子里面的动物——没有自由、眼神永远无助。犹豫了好一阵子,又觉得新冠疫情结束,可以将去北京大兴野生动物园看小熊猫当作二〇二三年看看大大世界的第一站——怀着期待和忐忑的情绪,小熊猫我们来了。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/85sLrEIf6AWzC34.jpg\" alt=\"休憩的小熊猫\" />\n <figcaption>休憩的小熊猫</figcaption>\n</figure>\n\n### 规划\n\n下定决心,忽悠好好友,开始研究攻略、做规划。\n\n**时间线**\n从小红书的关于大兴野生动物园的视频来看,排除掉周六日——北京人太多人太多。然后周一猛兽区不营业,最终选择3月28日周二请假一天去看小熊猫。出发时间是早上 6:00,意味着 5:30 前就要起床——青春坟墓依赖症同学面临者极大挑战。\n<figure>\n <img style=\"width: 100%;max-width:320px;\" src=\"//solome.js.org/static/beijing-wildlife-park-play/timing.svg\" alt=\"时间线安排\" />\n <figcaption>时间线安排</figcaption>\n</figure>\n\n**交通**\n我们两波人都选择最实惠的交通工具(地铁和公交),一波回龙观到大兴野生动物园(13元/2小时9分钟),另一波望京到大兴野生动物园(12元/2小时5分钟)。感叹北京人多地广之外,交通还贼便携。\n\n**蔬菜、水果**\n筹备了苹果、胡萝卜和白菜。进园是不让带的,奈何周二人少,门禁管理阿姨也是睁一只眼放我们大背包进去了。\n\n### 心满意足\n\n**春天**\n\n星期二请假一天入野生动物园确实是一个无比正确的选择。我们入园时已经九点多了(晚于预期时间近一个小时),但人真的很少——类似于在逛一个普普通通的小区公园。按照攻略,搭着园区代步车直奔猛兽区,到达目的地后却被告知「猛兽区十一点之后才开放」。\n\n好吧,攻略往往是无效的,计划永远滞后于变化。突然间只能开始漫无目的的乱逛,期间遇到不少初春盛开的樱花、杏花特别吸引眼球,色泽浓厚。喜欢看花,就像看过一段人生,开花前的煎熬、绽放时的耀眼、香断凋谢后的无情——好像都刻骨铭心般经历过一番,却又如流星稍纵即逝。\n\n这时携带的重量超两斤的佳能相机派上用途,花儿背景虚化的效果手机怎么拍都达不到——新冠疫情三年,这台佳能一直在吃灰,她也一定在等待着重见天日、被重用的时刻吧。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/lnsU7M4cSLvypzb.jpg\" alt=\"春天盛开的杏花\" />\n <figcaption>春天盛开的杏花</figcaption>\n</figure>\n\n**鹦鹉**\n\n第一站有趣的是鹦鹉——未进禽区就已经听到鸟儿叽叽喳喳声不绝于耳。进去后,这些鸟儿种类还真不少(反而显得空间过于压抑),也不是特别怕人。其中一只体型比较大的鸟儿(可能是鹦鹉的一个种类)看上我衣服上纽扣——可能是被当作坚果了吧,一直尝试用自己的啄尝试抠下来——阵势挺吓人的,但是第一次这么近距离接触这种鸟类也是特别有喜感。\n\n唯一遗憾的时,这次来动物园是冲着小熊猫来的,我并没有准备瓜子、花生这类坚果,没有达到更有趣的互动。还有,禽区内我一直在想「我家里养的那两只猫甩到这里,他俩一定认为这里就是游戏的天堂」,不知道是他俩乐呵还是受群鸟欺负。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/QYdOzVbJx1lB7qK.jpg\" alt=\"谈情说爱的鹦鹉\" />\n <figcaption>谈情说爱的鹦鹉</figcaption>\n</figure>\n\n**长尾猴**\n\n给我感觉 **快乐就应该是这个样子,但是自我小学毕业之后就再也没体验过这种快乐**,我知道那种快乐是什么样子的,但是我知道我往后人生很难再会体验到。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/kStKQ5uT8MfUyNz.jpg\" alt=\"乐呵的长尾猴\" />\n <figcaption>乐呵的长尾猴</figcaption>\n</figure>\n\n**小熊猫**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/1CubiN6DwyzhaMU.jpg\" alt=\"乞食的小熊猫\" />\n <figcaption>乞食的小熊猫</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/cnNBUu46jpYalWq.jpg\" alt=\"卖萌的小熊猫\" />\n <figcaption>卖萌的小熊猫</figcaption>\n</figure>\n\n**猛禽区**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HKJnwB7zEOcNC69.jpg\" alt=\"睡午觉的母狮\" />\n <figcaption>睡午觉的母狮</figcaption>\n</figure>\n\n**羊驼**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xGRQfrYodbKAIvs.jpg\" alt=\"奇奇怪怪的羊驼\" />\n <figcaption>奇奇怪怪的羊驼</figcaption>\n</figure>\n\n**归途**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xNaAXFOjoi1byR7.jpg\" alt=\"一路疲倦\" />\n <figcaption>一路疲倦</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HfUlDCrjZsea1Xo.jpg\" alt=\"归途背影\" />\n <figcaption>归途背影</figcaption>\n</figure>\n\n### 最后\n\n**[小红书](https://www.xiaohongshu.com/explore)为啥吸引我?**\n\n主要有两点:① 发现页推荐逻辑清晰、干净;② 搜索结果价值高。其中 ① 体现在首屏给四个(iPhone 版)推荐项,分别是近期访问相关(科技资讯)、猫、Switch 相关和一个手机壳赞助(广告)——无论刷新几次,这四类大方向不会存在过多变化——而淘宝、京东等的推荐,信息量太大,巴不得吸引我掏出口袋里那几个子。而 ② 是吸引我持续使用的最主要原因——我把小红书当百度来用,有兴趣的同学可以用「猫粮」、「感冒药」分别用小红书、百度搜索对比下,看看谁的结果是搜索者真正想要的——又得吐槽百度这家公司产品有多「废」。\n\n**动物园是罪恶的?**\n","source":"_posts/beijing-wildlife-park-play.md","raw":"---\ntitle: '北京漫游:大兴野生动物园'\ndate: 2023-03-19 23:30:00 +0800\ncomments: true\ncategories: 日常碎碎唸\n---\n\n### 缘起\n\n最近恋上[小红书](https://www.xiaohongshu.com/explore)(很难想象我会成为**小红书**的重度用户)——也就是在某天晚上在小红书上刷到了大兴野生动物园里面关于小熊猫的内容——天啊~为什么地球上有这么可爱的小动物!\n\n我想去现场瞧一瞧。但曾经逛西安海洋馆的悲伤记忆还在,我实在接受不了关在笼子里面的动物——没有自由、眼神永远无助。犹豫了好一阵子,又觉得新冠疫情结束,可以将去北京大兴野生动物园看小熊猫当作二〇二三年看看大大世界的第一站——怀着期待和忐忑的情绪,小熊猫我们来了。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/85sLrEIf6AWzC34.jpg\" alt=\"休憩的小熊猫\" />\n <figcaption>休憩的小熊猫</figcaption>\n</figure>\n\n### 规划\n\n下定决心,忽悠好好友,开始研究攻略、做规划。\n\n**时间线**\n从小红书的关于大兴野生动物园的视频来看,排除掉周六日——北京人太多人太多。然后周一猛兽区不营业,最终选择3月28日周二请假一天去看小熊猫。出发时间是早上 6:00,意味着 5:30 前就要起床——青春坟墓依赖症同学面临者极大挑战。\n<figure>\n <img style=\"width: 100%;max-width:320px;\" src=\"//solome.js.org/static/beijing-wildlife-park-play/timing.svg\" alt=\"时间线安排\" />\n <figcaption>时间线安排</figcaption>\n</figure>\n\n**交通**\n我们两波人都选择最实惠的交通工具(地铁和公交),一波回龙观到大兴野生动物园(13元/2小时9分钟),另一波望京到大兴野生动物园(12元/2小时5分钟)。感叹北京人多地广之外,交通还贼便携。\n\n**蔬菜、水果**\n筹备了苹果、胡萝卜和白菜。进园是不让带的,奈何周二人少,门禁管理阿姨也是睁一只眼放我们大背包进去了。\n\n### 心满意足\n\n**春天**\n\n星期二请假一天入野生动物园确实是一个无比正确的选择。我们入园时已经九点多了(晚于预期时间近一个小时),但人真的很少——类似于在逛一个普普通通的小区公园。按照攻略,搭着园区代步车直奔猛兽区,到达目的地后却被告知「猛兽区十一点之后才开放」。\n\n好吧,攻略往往是无效的,计划永远滞后于变化。突然间只能开始漫无目的的乱逛,期间遇到不少初春盛开的樱花、杏花特别吸引眼球,色泽浓厚。喜欢看花,就像看过一段人生,开花前的煎熬、绽放时的耀眼、香断凋谢后的无情——好像都刻骨铭心般经历过一番,却又如流星稍纵即逝。\n\n这时携带的重量超两斤的佳能相机派上用途,花儿背景虚化的效果手机怎么拍都达不到——新冠疫情三年,这台佳能一直在吃灰,她也一定在等待着重见天日、被重用的时刻吧。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/lnsU7M4cSLvypzb.jpg\" alt=\"春天盛开的杏花\" />\n <figcaption>春天盛开的杏花</figcaption>\n</figure>\n\n**鹦鹉**\n\n第一站有趣的是鹦鹉——未进禽区就已经听到鸟儿叽叽喳喳声不绝于耳。进去后,这些鸟儿种类还真不少(反而显得空间过于压抑),也不是特别怕人。其中一只体型比较大的鸟儿(可能是鹦鹉的一个种类)看上我衣服上纽扣——可能是被当作坚果了吧,一直尝试用自己的啄尝试抠下来——阵势挺吓人的,但是第一次这么近距离接触这种鸟类也是特别有喜感。\n\n唯一遗憾的时,这次来动物园是冲着小熊猫来的,我并没有准备瓜子、花生这类坚果,没有达到更有趣的互动。还有,禽区内我一直在想「我家里养的那两只猫甩到这里,他俩一定认为这里就是游戏的天堂」,不知道是他俩乐呵还是受群鸟欺负。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/QYdOzVbJx1lB7qK.jpg\" alt=\"谈情说爱的鹦鹉\" />\n <figcaption>谈情说爱的鹦鹉</figcaption>\n</figure>\n\n**长尾猴**\n\n给我感觉 **快乐就应该是这个样子,但是自我小学毕业之后就再也没体验过这种快乐**,我知道那种快乐是什么样子的,但是我知道我往后人生很难再会体验到。\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/kStKQ5uT8MfUyNz.jpg\" alt=\"乐呵的长尾猴\" />\n <figcaption>乐呵的长尾猴</figcaption>\n</figure>\n\n**小熊猫**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/1CubiN6DwyzhaMU.jpg\" alt=\"乞食的小熊猫\" />\n <figcaption>乞食的小熊猫</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/cnNBUu46jpYalWq.jpg\" alt=\"卖萌的小熊猫\" />\n <figcaption>卖萌的小熊猫</figcaption>\n</figure>\n\n**猛禽区**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HKJnwB7zEOcNC69.jpg\" alt=\"睡午觉的母狮\" />\n <figcaption>睡午觉的母狮</figcaption>\n</figure>\n\n**羊驼**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xGRQfrYodbKAIvs.jpg\" alt=\"奇奇怪怪的羊驼\" />\n <figcaption>奇奇怪怪的羊驼</figcaption>\n</figure>\n\n**归途**\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xNaAXFOjoi1byR7.jpg\" alt=\"一路疲倦\" />\n <figcaption>一路疲倦</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HfUlDCrjZsea1Xo.jpg\" alt=\"归途背影\" />\n <figcaption>归途背影</figcaption>\n</figure>\n\n### 最后\n\n**[小红书](https://www.xiaohongshu.com/explore)为啥吸引我?**\n\n主要有两点:① 发现页推荐逻辑清晰、干净;② 搜索结果价值高。其中 ① 体现在首屏给四个(iPhone 版)推荐项,分别是近期访问相关(科技资讯)、猫、Switch 相关和一个手机壳赞助(广告)——无论刷新几次,这四类大方向不会存在过多变化——而淘宝、京东等的推荐,信息量太大,巴不得吸引我掏出口袋里那几个子。而 ② 是吸引我持续使用的最主要原因——我把小红书当百度来用,有兴趣的同学可以用「猫粮」、「感冒药」分别用小红书、百度搜索对比下,看看谁的结果是搜索者真正想要的——又得吐槽百度这家公司产品有多「废」。\n\n**动物园是罪恶的?**\n","slug":"beijing-wildlife-park-play","published":1,"updated":"2023-11-11T15:06:32.310Z","_id":"clookn3rp0000ku3yds6l96mo","layout":"post","photos":[],"link":"","content":"<h3 id=\"缘起\"><a href=\"#缘起\" class=\"headerlink\" title=\"缘起\"></a>缘起</h3><p>最近恋上<a href=\"https://www.xiaohongshu.com/explore\">小红书</a>(很难想象我会成为<strong>小红书</strong>的重度用户)——也就是在某天晚上在小红书上刷到了大兴野生动物园里面关于小熊猫的内容——天啊~为什么地球上有这么可爱的小动物!</p>\n<p>我想去现场瞧一瞧。但曾经逛西安海洋馆的悲伤记忆还在,我实在接受不了关在笼子里面的动物——没有自由、眼神永远无助。犹豫了好一阵子,又觉得新冠疫情结束,可以将去北京大兴野生动物园看小熊猫当作二〇二三年看看大大世界的第一站——怀着期待和忐忑的情绪,小熊猫我们来了。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/85sLrEIf6AWzC34.jpg\" alt=\"休憩的小熊猫\" />\n <figcaption>休憩的小熊猫</figcaption>\n</figure>\n\n<h3 id=\"规划\"><a href=\"#规划\" class=\"headerlink\" title=\"规划\"></a>规划</h3><p>下定决心,忽悠好好友,开始研究攻略、做规划。</p>\n<p><strong>时间线</strong><br>从小红书的关于大兴野生动物园的视频来看,排除掉周六日——北京人太多人太多。然后周一猛兽区不营业,最终选择3月28日周二请假一天去看小熊猫。出发时间是早上 6:00,意味着 5:30 前就要起床——青春坟墓依赖症同学面临者极大挑战。</p>\n<figure>\n <img style=\"width: 100%;max-width:320px;\" src=\"//solome.js.org/static/beijing-wildlife-park-play/timing.svg\" alt=\"时间线安排\" />\n <figcaption>时间线安排</figcaption>\n</figure>\n\n<p><strong>交通</strong><br>我们两波人都选择最实惠的交通工具(地铁和公交),一波回龙观到大兴野生动物园(13元/2小时9分钟),另一波望京到大兴野生动物园(12元/2小时5分钟)。感叹北京人多地广之外,交通还贼便携。</p>\n<p><strong>蔬菜、水果</strong><br>筹备了苹果、胡萝卜和白菜。进园是不让带的,奈何周二人少,门禁管理阿姨也是睁一只眼放我们大背包进去了。</p>\n<h3 id=\"心满意足\"><a href=\"#心满意足\" class=\"headerlink\" title=\"心满意足\"></a>心满意足</h3><p><strong>春天</strong></p>\n<p>星期二请假一天入野生动物园确实是一个无比正确的选择。我们入园时已经九点多了(晚于预期时间近一个小时),但人真的很少——类似于在逛一个普普通通的小区公园。按照攻略,搭着园区代步车直奔猛兽区,到达目的地后却被告知「猛兽区十一点之后才开放」。</p>\n<p>好吧,攻略往往是无效的,计划永远滞后于变化。突然间只能开始漫无目的的乱逛,期间遇到不少初春盛开的樱花、杏花特别吸引眼球,色泽浓厚。喜欢看花,就像看过一段人生,开花前的煎熬、绽放时的耀眼、香断凋谢后的无情——好像都刻骨铭心般经历过一番,却又如流星稍纵即逝。</p>\n<p>这时携带的重量超两斤的佳能相机派上用途,花儿背景虚化的效果手机怎么拍都达不到——新冠疫情三年,这台佳能一直在吃灰,她也一定在等待着重见天日、被重用的时刻吧。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/lnsU7M4cSLvypzb.jpg\" alt=\"春天盛开的杏花\" />\n <figcaption>春天盛开的杏花</figcaption>\n</figure>\n\n<p><strong>鹦鹉</strong></p>\n<p>第一站有趣的是鹦鹉——未进禽区就已经听到鸟儿叽叽喳喳声不绝于耳。进去后,这些鸟儿种类还真不少(反而显得空间过于压抑),也不是特别怕人。其中一只体型比较大的鸟儿(可能是鹦鹉的一个种类)看上我衣服上纽扣——可能是被当作坚果了吧,一直尝试用自己的啄尝试抠下来——阵势挺吓人的,但是第一次这么近距离接触这种鸟类也是特别有喜感。</p>\n<p>唯一遗憾的时,这次来动物园是冲着小熊猫来的,我并没有准备瓜子、花生这类坚果,没有达到更有趣的互动。还有,禽区内我一直在想「我家里养的那两只猫甩到这里,他俩一定认为这里就是游戏的天堂」,不知道是他俩乐呵还是受群鸟欺负。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/QYdOzVbJx1lB7qK.jpg\" alt=\"谈情说爱的鹦鹉\" />\n <figcaption>谈情说爱的鹦鹉</figcaption>\n</figure>\n\n<p><strong>长尾猴</strong></p>\n<p>给我感觉 <strong>快乐就应该是这个样子,但是自我小学毕业之后就再也没体验过这种快乐</strong>,我知道那种快乐是什么样子的,但是我知道我往后人生很难再会体验到。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/kStKQ5uT8MfUyNz.jpg\" alt=\"乐呵的长尾猴\" />\n <figcaption>乐呵的长尾猴</figcaption>\n</figure>\n\n<p><strong>小熊猫</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/1CubiN6DwyzhaMU.jpg\" alt=\"乞食的小熊猫\" />\n <figcaption>乞食的小熊猫</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/cnNBUu46jpYalWq.jpg\" alt=\"卖萌的小熊猫\" />\n <figcaption>卖萌的小熊猫</figcaption>\n</figure>\n\n<p><strong>猛禽区</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HKJnwB7zEOcNC69.jpg\" alt=\"睡午觉的母狮\" />\n <figcaption>睡午觉的母狮</figcaption>\n</figure>\n\n<p><strong>羊驼</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xGRQfrYodbKAIvs.jpg\" alt=\"奇奇怪怪的羊驼\" />\n <figcaption>奇奇怪怪的羊驼</figcaption>\n</figure>\n\n<p><strong>归途</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xNaAXFOjoi1byR7.jpg\" alt=\"一路疲倦\" />\n <figcaption>一路疲倦</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HfUlDCrjZsea1Xo.jpg\" alt=\"归途背影\" />\n <figcaption>归途背影</figcaption>\n</figure>\n\n<h3 id=\"最后\"><a href=\"#最后\" class=\"headerlink\" title=\"最后\"></a>最后</h3><p><strong><a href=\"https://www.xiaohongshu.com/explore\">小红书</a>为啥吸引我?</strong></p>\n<p>主要有两点:① 发现页推荐逻辑清晰、干净;② 搜索结果价值高。其中 ① 体现在首屏给四个(iPhone 版)推荐项,分别是近期访问相关(科技资讯)、猫、Switch 相关和一个手机壳赞助(广告)——无论刷新几次,这四类大方向不会存在过多变化——而淘宝、京东等的推荐,信息量太大,巴不得吸引我掏出口袋里那几个子。而 ② 是吸引我持续使用的最主要原因——我把小红书当百度来用,有兴趣的同学可以用「猫粮」、「感冒药」分别用小红书、百度搜索对比下,看看谁的结果是搜索者真正想要的——又得吐槽百度这家公司产品有多「废」。</p>\n<p><strong>动物园是罪恶的?</strong></p>\n","site":{"data":{}},"excerpt":"","more":"<h3 id=\"缘起\"><a href=\"#缘起\" class=\"headerlink\" title=\"缘起\"></a>缘起</h3><p>最近恋上<a href=\"https://www.xiaohongshu.com/explore\">小红书</a>(很难想象我会成为<strong>小红书</strong>的重度用户)——也就是在某天晚上在小红书上刷到了大兴野生动物园里面关于小熊猫的内容——天啊~为什么地球上有这么可爱的小动物!</p>\n<p>我想去现场瞧一瞧。但曾经逛西安海洋馆的悲伤记忆还在,我实在接受不了关在笼子里面的动物——没有自由、眼神永远无助。犹豫了好一阵子,又觉得新冠疫情结束,可以将去北京大兴野生动物园看小熊猫当作二〇二三年看看大大世界的第一站——怀着期待和忐忑的情绪,小熊猫我们来了。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/85sLrEIf6AWzC34.jpg\" alt=\"休憩的小熊猫\" />\n <figcaption>休憩的小熊猫</figcaption>\n</figure>\n\n<h3 id=\"规划\"><a href=\"#规划\" class=\"headerlink\" title=\"规划\"></a>规划</h3><p>下定决心,忽悠好好友,开始研究攻略、做规划。</p>\n<p><strong>时间线</strong><br>从小红书的关于大兴野生动物园的视频来看,排除掉周六日——北京人太多人太多。然后周一猛兽区不营业,最终选择3月28日周二请假一天去看小熊猫。出发时间是早上 6:00,意味着 5:30 前就要起床——青春坟墓依赖症同学面临者极大挑战。</p>\n<figure>\n <img style=\"width: 100%;max-width:320px;\" src=\"//solome.js.org/static/beijing-wildlife-park-play/timing.svg\" alt=\"时间线安排\" />\n <figcaption>时间线安排</figcaption>\n</figure>\n\n<p><strong>交通</strong><br>我们两波人都选择最实惠的交通工具(地铁和公交),一波回龙观到大兴野生动物园(13元/2小时9分钟),另一波望京到大兴野生动物园(12元/2小时5分钟)。感叹北京人多地广之外,交通还贼便携。</p>\n<p><strong>蔬菜、水果</strong><br>筹备了苹果、胡萝卜和白菜。进园是不让带的,奈何周二人少,门禁管理阿姨也是睁一只眼放我们大背包进去了。</p>\n<h3 id=\"心满意足\"><a href=\"#心满意足\" class=\"headerlink\" title=\"心满意足\"></a>心满意足</h3><p><strong>春天</strong></p>\n<p>星期二请假一天入野生动物园确实是一个无比正确的选择。我们入园时已经九点多了(晚于预期时间近一个小时),但人真的很少——类似于在逛一个普普通通的小区公园。按照攻略,搭着园区代步车直奔猛兽区,到达目的地后却被告知「猛兽区十一点之后才开放」。</p>\n<p>好吧,攻略往往是无效的,计划永远滞后于变化。突然间只能开始漫无目的的乱逛,期间遇到不少初春盛开的樱花、杏花特别吸引眼球,色泽浓厚。喜欢看花,就像看过一段人生,开花前的煎熬、绽放时的耀眼、香断凋谢后的无情——好像都刻骨铭心般经历过一番,却又如流星稍纵即逝。</p>\n<p>这时携带的重量超两斤的佳能相机派上用途,花儿背景虚化的效果手机怎么拍都达不到——新冠疫情三年,这台佳能一直在吃灰,她也一定在等待着重见天日、被重用的时刻吧。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/lnsU7M4cSLvypzb.jpg\" alt=\"春天盛开的杏花\" />\n <figcaption>春天盛开的杏花</figcaption>\n</figure>\n\n<p><strong>鹦鹉</strong></p>\n<p>第一站有趣的是鹦鹉——未进禽区就已经听到鸟儿叽叽喳喳声不绝于耳。进去后,这些鸟儿种类还真不少(反而显得空间过于压抑),也不是特别怕人。其中一只体型比较大的鸟儿(可能是鹦鹉的一个种类)看上我衣服上纽扣——可能是被当作坚果了吧,一直尝试用自己的啄尝试抠下来——阵势挺吓人的,但是第一次这么近距离接触这种鸟类也是特别有喜感。</p>\n<p>唯一遗憾的时,这次来动物园是冲着小熊猫来的,我并没有准备瓜子、花生这类坚果,没有达到更有趣的互动。还有,禽区内我一直在想「我家里养的那两只猫甩到这里,他俩一定认为这里就是游戏的天堂」,不知道是他俩乐呵还是受群鸟欺负。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/QYdOzVbJx1lB7qK.jpg\" alt=\"谈情说爱的鹦鹉\" />\n <figcaption>谈情说爱的鹦鹉</figcaption>\n</figure>\n\n<p><strong>长尾猴</strong></p>\n<p>给我感觉 <strong>快乐就应该是这个样子,但是自我小学毕业之后就再也没体验过这种快乐</strong>,我知道那种快乐是什么样子的,但是我知道我往后人生很难再会体验到。</p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/kStKQ5uT8MfUyNz.jpg\" alt=\"乐呵的长尾猴\" />\n <figcaption>乐呵的长尾猴</figcaption>\n</figure>\n\n<p><strong>小熊猫</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/1CubiN6DwyzhaMU.jpg\" alt=\"乞食的小熊猫\" />\n <figcaption>乞食的小熊猫</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/cnNBUu46jpYalWq.jpg\" alt=\"卖萌的小熊猫\" />\n <figcaption>卖萌的小熊猫</figcaption>\n</figure>\n\n<p><strong>猛禽区</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HKJnwB7zEOcNC69.jpg\" alt=\"睡午觉的母狮\" />\n <figcaption>睡午觉的母狮</figcaption>\n</figure>\n\n<p><strong>羊驼</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xGRQfrYodbKAIvs.jpg\" alt=\"奇奇怪怪的羊驼\" />\n <figcaption>奇奇怪怪的羊驼</figcaption>\n</figure>\n\n<p><strong>归途</strong></p>\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/xNaAXFOjoi1byR7.jpg\" alt=\"一路疲倦\" />\n <figcaption>一路疲倦</figcaption>\n</figure>\n\n<figure>\n <img style=\"width: 100%;\" src=\"//s2.loli.net/2023/11/07/HfUlDCrjZsea1Xo.jpg\" alt=\"归途背影\" />\n <figcaption>归途背影</figcaption>\n</figure>\n\n<h3 id=\"最后\"><a href=\"#最后\" class=\"headerlink\" title=\"最后\"></a>最后</h3><p><strong><a href=\"https://www.xiaohongshu.com/explore\">小红书</a>为啥吸引我?</strong></p>\n<p>主要有两点:① 发现页推荐逻辑清晰、干净;② 搜索结果价值高。其中 ① 体现在首屏给四个(iPhone 版)推荐项,分别是近期访问相关(科技资讯)、猫、Switch 相关和一个手机壳赞助(广告)——无论刷新几次,这四类大方向不会存在过多变化——而淘宝、京东等的推荐,信息量太大,巴不得吸引我掏出口袋里那几个子。而 ② 是吸引我持续使用的最主要原因——我把小红书当百度来用,有兴趣的同学可以用「猫粮」、「感冒药」分别用小红书、百度搜索对比下,看看谁的结果是搜索者真正想要的——又得吐槽百度这家公司产品有多「废」。</p>\n<p><strong>动物园是罪恶的?</strong></p>\n"}],"PostAsset":[],"PostCategory":[{"post_id":"clooia2lr0000ln3y89ufbnrc","category_id":"clooia2lw0002ln3ye5m9effn","_id":"clooia2m10009ln3ybpjk35qq"},{"post_id":"clooia2lv0001ln3ycg4b3ff3","category_id":"clooia2lz0006ln3y39m18ko5","_id":"clooia2m2000cln3yhxhk2wnw"},{"post_id":"clooia2m1000bln3y0n7khjqs","category_id":"clooia2lz0006ln3y39m18ko5","_id":"clooia2m2000eln3y1qjpht8q"},{"post_id":"clooia2lx0003ln3ybppl4cvn","category_id":"clooia2m1000aln3ye8u809wv","_id":"clooia2m2000gln3y3zfwc7ky"},{"post_id":"clooia2ly0004ln3yfrsg5p40","category_id":"clooia2m2000dln3y4wr0agm1","_id":"clooia2m3000iln3yaxg549l6"},{"post_id":"clooia2lz0005ln3yesvif8os","category_id":"clooia2m1000aln3ye8u809wv","_id":"clooia2m4000kln3ycple3lo6"},{"post_id":"clooia2m00007ln3yd0rkctvb","category_id":"clooia2m2000dln3y4wr0agm1","_id":"clooia2m4000lln3y5hpt6won"},{"post_id":"clooia2m00008ln3y1zloau1n","category_id":"clooia2m2000dln3y4wr0agm1","_id":"clooia2m4000mln3y870galvn"},{"post_id":"clooia2m5000nln3yhnpb8l6s","category_id":"clooia2lw0002ln3ye5m9effn","_id":"clooia2m6000oln3yglazgph9"},{"post_id":"clookn3rp0000ku3yds6l96mo","category_id":"clooia2lw0002ln3ye5m9effn","_id":"clookuhai000aku3yax62heke"}],"PostTag":[],"Tag":[]}}