[web] interviews/questions: translate system design contents to zh-CN
This commit is contained in:
parent
7397896920
commit
ebedac8cbf
File diff suppressed because one or more lines are too long
32
apps/web/src/__generated__/questions/system-design/chat-application-messenger/zh-CN.json
generated
Normal file
32
apps/web/src/__generated__/questions/system-design/chat-application-messenger/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
34
apps/web/src/__generated__/questions/system-design/collaborative-editor-google-docs/zh-CN.json
generated
Normal file
34
apps/web/src/__generated__/questions/system-design/collaborative-editor-google-docs/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"description": "var Component=(()=>{var x=Object.create;var o=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,j=Object.prototype.hasOwnProperty;var p=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports),_=(e,n)=>{for(var r in n)o(e,r,{get:n[r],enumerable:!0})},i=(e,n,r,l)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let c of u(n))!j.call(e,c)&&c!==r&&o(e,c,{get:()=>n[c],enumerable:!(l=d(n,c))||l.enumerable});return e};var g=(e,n,r)=>(r=e!=null?x(m(e)):{},i(n||!e||!e.__esModule?o(r,\"default\",{value:e,enumerable:!0}):r,e)),f=e=>i(o({},\"__esModule\",{value:!0}),e);var h=p((G,s)=>{s.exports=_jsx_runtime});var D={};_(D,{default:()=>C,frontmatter:()=>M});var t=g(h()),M={title:\"Google Sheets\",excerpt:\"\\u8BBE\\u8BA1\\u4E00\\u4E2A\\u7C7B\\u4F3C Google Sheet \\u548C Excel \\u7684\\u534F\\u4F5C\\u7535\\u5B50\\u8868\\u683C\"};function a(e){let n=Object.assign({h2:\"h2\",p:\"p\",h3:\"h3\",ul:\"ul\",li:\"li\"},e.components);return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(n.h2,{children:\"\\u95EE\\u9898\"}),`\n`,(0,t.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,t.jsx)(n.h3,{children:\"\\u771F\\u5B9E\\u6848\\u4F8B\"}),`\n`,(0,t.jsxs)(n.ul,{children:[`\n`,(0,t.jsx)(n.li,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,t.jsx)(n.li,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`]})]})}function b(e={}){let{wrapper:n}=e.components||{};return n?(0,t.jsx)(n,Object.assign({},e,{children:(0,t.jsx)(a,e)})):a(e)}var C=b;return f(D);})();\n;return Component;",
|
||||
"metadata": {
|
||||
"access": "premium",
|
||||
"author": null,
|
||||
"companies": [],
|
||||
"created": 1630800000,
|
||||
"difficulty": "hard",
|
||||
"duration": 40,
|
||||
"excerpt": "设计一个类似 Google Sheet 和 Excel 的协作电子表格",
|
||||
"featured": false,
|
||||
"format": "system-design",
|
||||
"frameworkDefault": null,
|
||||
"frameworks": [],
|
||||
"href": "/questions/system-design/collaborative-spreadsheet-google-sheets",
|
||||
"importance": "low",
|
||||
"languages": [],
|
||||
"nextQuestions": [],
|
||||
"published": true,
|
||||
"ranking": 14,
|
||||
"similarQuestions": [],
|
||||
"slug": "collaborative-spreadsheet-google-sheets",
|
||||
"subtitle": null,
|
||||
"title": "Google Sheets",
|
||||
"topics": []
|
||||
},
|
||||
"solution": "var Component=(()=>{var s=Object.create;var h=Object.defineProperty;var o=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var O=Object.getPrototypeOf,v=Object.prototype.hasOwnProperty;var D=(i,n)=>()=>(n||i((n={exports:{}}).exports,n),n.exports),T=(i,n)=>{for(var d in n)h(i,d,{get:n[d],enumerable:!0})},c=(i,n,d,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let l of u(n))!v.call(i,l)&&l!==d&&h(i,l,{get:()=>n[l],enumerable:!(r=o(n,l))||r.enumerable});return i};var m=(i,n,d)=>(d=i!=null?s(O(i)):{},c(n||!i||!i.__esModule?h(d,\"default\",{value:i,enumerable:!0}):d,i)),x=i=>c(h({},\"__esModule\",{value:!0}),i);var a=D((M,t)=>{t.exports=_jsx_runtime});var f={};T(f,{default:()=>b,tableOfContents:()=>j});var e=m(a()),j=[{depth:2,value:\"\\u9700\\u6C42\\u63A2\\u7D22\",id:\"\\u9700\\u6C42\\u63A2\\u7D22\"},{depth:2,value:\"\\u67B6\\u6784/\\u9AD8\\u5C42\\u8BBE\\u8BA1\",id:\"\\u67B6\\u6784\\u9AD8\\u5C42\\u8BBE\\u8BA1\"},{depth:2,value:\"\\u6570\\u636E\\u6A21\\u578B\",id:\"\\u6570\\u636E\\u6A21\\u578B\"},{depth:2,value:\"\\u63A5\\u53E3\\u5B9A\\u4E49 (API)\",id:\"\\u63A5\\u53E3\\u5B9A\\u4E49-api\"},{depth:2,value:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",id:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",children:[{depth:3,value:\"\\u6E32\\u67D3\\uFF1ADOM vs Canvas\",id:\"\\u6E32\\u67D3dom-vs-canvas\"},{depth:3,value:\"\\u8868\\u683C\\u865A\\u62DF\\u5316\",id:\"\\u8868\\u683C\\u865A\\u62DF\\u5316\"},{depth:3,value:\"\\u516C\\u5F0F\\u89E3\\u6790\",id:\"\\u516C\\u5F0F\\u89E3\\u6790\"},{depth:3,value:\"\\u683C\\u5F0F\\u5316\",id:\"\\u683C\\u5F0F\\u5316\"}]},{depth:2,value:\"\\u53C2\\u8003\",id:\"\\u53C2\\u8003\"}];function p(i){let n=Object.assign({h2:\"h2\",p:\"p\",ul:\"ul\",li:\"li\",hr:\"hr\",h3:\"h3\"},i.components);return(0,e.jsxs)(e.Fragment,{children:[(0,e.jsx)(n.h2,{id:\"\\u9700\\u6C42\\u63A2\\u7D22\",children:\"\\u9700\\u6C42\\u63A2\\u7D22\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsxs)(n.ul,{children:[`\n`,(0,e.jsx)(n.li,{children:\"\\u7F16\\u8F91\\u5355\\u5143\\u683C\\u503C\"}),`\n`,(0,e.jsx)(n.li,{children:\"\\u5355\\u5143\\u683C\\u683C\\u5F0F\"}),`\n`,(0,e.jsx)(n.li,{children:\"\\u516C\\u5F0F\"}),`\n`]}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u67B6\\u6784\\u9AD8\\u5C42\\u8BBE\\u8BA1\",children:\"\\u67B6\\u6784/\\u9AD8\\u5C42\\u8BBE\\u8BA1\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsxs)(n.ul,{children:[`\n`,(0,e.jsx)(n.li,{children:\"App Root\"}),`\n`,(0,e.jsx)(n.li,{children:\"Table\"}),`\n`,(0,e.jsx)(n.li,{children:\"Toolbar\"}),`\n`,(0,e.jsx)(n.li,{children:\"\\u516C\\u5F0F\\u884C\"}),`\n`]}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u6570\\u636E\\u6A21\\u578B\",children:\"\\u6570\\u636E\\u6A21\\u578B\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u63A5\\u53E3\\u5B9A\\u4E49-api\",children:\"\\u63A5\\u53E3\\u5B9A\\u4E49 (API)\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",children:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.h3,{id:\"\\u6E32\\u67D3dom-vs-canvas\",children:\"\\u6E32\\u67D3\\uFF1ADOM vs Canvas\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.h3,{id:\"\\u8868\\u683C\\u865A\\u62DF\\u5316\",children:\"\\u8868\\u683C\\u865A\\u62DF\\u5316\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.h3,{id:\"\\u516C\\u5F0F\\u89E3\\u6790\",children:\"\\u516C\\u5F0F\\u89E3\\u6790\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsxs)(n.ul,{children:[`\n`,(0,e.jsx)(n.li,{children:\"\\u62D3\\u6251\\u6392\\u5E8F\\u4EE5\\u68C0\\u6D4B\\u5FAA\\u73AF\\u548C\\u4F9D\\u8D56\\u5173\\u7CFB\\u3002\"}),`\n`,(0,e.jsx)(n.li,{children:\"\\u9012\\u5F52\\u5730\\u89E3\\u6790\\u4F9D\\u8D56\\u5173\\u7CFB\\u548C\\u503C\\u3002\"}),`\n`]}),`\n`,(0,e.jsx)(n.h3,{id:\"\\u683C\\u5F0F\\u5316\",children:\"\\u683C\\u5F0F\\u5316\"}),`\n`,(0,e.jsxs)(n.ul,{children:[`\n`,(0,e.jsx)(n.li,{children:\"\\u884C/\\u5217\\u7EA7\\u683C\\u5F0F\\u3002\"}),`\n`,(0,e.jsx)(n.li,{children:\"\\u5355\\u5143\\u683C\\u7EA7\\u683C\\u5F0F\\u8986\\u76D6\\u3002\"}),`\n`]}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u53C2\\u8003\",children:\"\\u53C2\\u8003\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"})]})}function _(i={}){let{wrapper:n}=i.components||{};return n?(0,e.jsx)(n,Object.assign({},i,{children:(0,e.jsx)(p,i)})):p(i)}var b=_;return x(f);})();\n;return Component;"
|
||||
}
|
||||
28
apps/web/src/__generated__/questions/system-design/diagram-tool-lucidchart/zh-CN.json
generated
Normal file
28
apps/web/src/__generated__/questions/system-design/diagram-tool-lucidchart/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
34
apps/web/src/__generated__/questions/system-design/dropdown-menu/zh-CN.json
generated
Normal file
34
apps/web/src/__generated__/questions/system-design/dropdown-menu/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
30
apps/web/src/__generated__/questions/system-design/e-commerce-amazon/zh-CN.json
generated
Normal file
30
apps/web/src/__generated__/questions/system-design/e-commerce-amazon/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
30
apps/web/src/__generated__/questions/system-design/email-client-outlook/zh-CN.json
generated
Normal file
30
apps/web/src/__generated__/questions/system-design/email-client-outlook/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
38
apps/web/src/__generated__/questions/system-design/image-carousel/zh-CN.json
generated
Normal file
38
apps/web/src/__generated__/questions/system-design/image-carousel/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
30
apps/web/src/__generated__/questions/system-design/music-streaming-spotify/zh-CN.json
generated
Normal file
30
apps/web/src/__generated__/questions/system-design/music-streaming-spotify/zh-CN.json
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"description": "var Component=(()=>{var d=Object.create;var c=Object.defineProperty;var u=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,p=Object.prototype.hasOwnProperty;var f=(t,n)=>()=>(n||t((n={exports:{}}).exports,n),n.exports),j=(t,n)=>{for(var r in n)c(t,r,{get:n[r],enumerable:!0})},s=(t,n,r,o)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let i of x(n))!p.call(t,i)&&i!==r&&c(t,i,{get:()=>n[i],enumerable:!(o=u(n,i))||o.enumerable});return t};var _=(t,n,r)=>(r=t!=null?d(m(t)):{},s(n||!t||!t.__esModule?c(r,\"default\",{value:t,enumerable:!0}):r,t)),g=t=>s(c({},\"__esModule\",{value:!0}),t);var a=f((F,l)=>{l.exports=_jsx_runtime});var C={};j(C,{default:()=>b,frontmatter:()=>y});var e=_(a()),y={title:\"\\u97F3\\u4E50\\u6D41\\u5A92\\u4F53\\uFF08\\u4F8B\\u5982 Spotify\\uFF09\",excerpt:\"\\u8BBE\\u8BA1\\u4E00\\u4E2A\\u50CF Spotify \\u548C Pandora \\u8FD9\\u6837\\u7684\\u97F3\\u4E50\\u6D41\\u5A92\\u4F53\\u7F51\\u7AD9\"};function h(t){let n=Object.assign({h2:\"h2\",p:\"p\",h3:\"h3\",ul:\"ul\",li:\"li\"},t.components);return(0,e.jsxs)(e.Fragment,{children:[(0,e.jsx)(n.h2,{children:\"\\u95EE\\u9898\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.h3,{children:\"\\u771F\\u5B9E\\u6848\\u4F8B\"}),`\n`,(0,e.jsxs)(n.ul,{children:[`\n`,(0,e.jsx)(n.li,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.li,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`]})]})}function M(t={}){let{wrapper:n}=t.components||{};return n?(0,e.jsx)(n,Object.assign({},t,{children:(0,e.jsx)(h,t)})):h(t)}var b=M;return g(C);})();\n;return Component;",
|
||||
"metadata": {
|
||||
"access": "premium",
|
||||
"author": null,
|
||||
"companies": [],
|
||||
"created": 1630800000,
|
||||
"difficulty": "hard",
|
||||
"duration": 30,
|
||||
"excerpt": "设计一个像 Spotify 和 Pandora 这样的音乐流媒体网站",
|
||||
"featured": false,
|
||||
"format": "system-design",
|
||||
"frameworkDefault": null,
|
||||
"frameworks": [],
|
||||
"href": "/questions/system-design/music-streaming-spotify",
|
||||
"importance": "medium",
|
||||
"languages": [],
|
||||
"nextQuestions": [],
|
||||
"published": true,
|
||||
"ranking": 15,
|
||||
"similarQuestions": [],
|
||||
"slug": "music-streaming-spotify",
|
||||
"subtitle": null,
|
||||
"title": "音乐流媒体(例如 Spotify)",
|
||||
"topics": [
|
||||
"networking"
|
||||
]
|
||||
},
|
||||
"solution": "var Component=(()=>{var s=Object.create;var i=Object.defineProperty;var o=Object.getOwnPropertyDescriptor;var O=Object.getOwnPropertyNames;var u=Object.getPrototypeOf,x=Object.prototype.hasOwnProperty;var m=(h,n)=>()=>(n||h((n={exports:{}}).exports,n),n.exports),D=(h,n)=>{for(var t in n)i(h,t,{get:n[t],enumerable:!0})},c=(h,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let d of O(n))!x.call(h,d)&&d!==t&&i(h,d,{get:()=>n[d],enumerable:!(r=o(n,d))||r.enumerable});return h};var j=(h,n,t)=>(t=h!=null?s(u(h)):{},c(n||!h||!h.__esModule?i(t,\"default\",{value:h,enumerable:!0}):t,h)),_=h=>c(i({},\"__esModule\",{value:!0}),h);var a=m((C,l)=>{l.exports=_jsx_runtime});var g={};D(g,{default:()=>f,tableOfContents:()=>v});var e=j(a()),v=[{depth:2,value:\"\\u9700\\u6C42\\u63A2\\u7D22\",id:\"\\u9700\\u6C42\\u63A2\\u7D22\"},{depth:2,value:\"\\u67B6\\u6784/\\u9AD8\\u5C42\\u8BBE\\u8BA1\",id:\"\\u67B6\\u6784\\u9AD8\\u5C42\\u8BBE\\u8BA1\"},{depth:2,value:\"\\u6570\\u636E\\u6A21\\u578B\",id:\"\\u6570\\u636E\\u6A21\\u578B\"},{depth:2,value:\"\\u63A5\\u53E3\\u5B9A\\u4E49 (API)\",id:\"\\u63A5\\u53E3\\u5B9A\\u4E49-api\"},{depth:2,value:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",id:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\"},{depth:2,value:\"\\u53C2\\u8003\",id:\"\\u53C2\\u8003\"}];function p(h){let n=Object.assign({h2:\"h2\",p:\"p\",hr:\"hr\"},h.components);return(0,e.jsxs)(e.Fragment,{children:[(0,e.jsx)(n.h2,{id:\"\\u9700\\u6C42\\u63A2\\u7D22\",children:\"\\u9700\\u6C42\\u63A2\\u7D22\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u67B6\\u6784\\u9AD8\\u5C42\\u8BBE\\u8BA1\",children:\"\\u67B6\\u6784/\\u9AD8\\u5C42\\u8BBE\\u8BA1\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u6570\\u636E\\u6A21\\u578B\",children:\"\\u6570\\u636E\\u6A21\\u578B\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u63A5\\u53E3\\u5B9A\\u4E49-api\",children:\"\\u63A5\\u53E3\\u5B9A\\u4E49 (API)\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",children:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u53C2\\u8003\",children:\"\\u53C2\\u8003\"}),`\n`,(0,e.jsx)(n.p,{children:\"TODO\"})]})}function T(h={}){let{wrapper:n}=h.components||{};return n?(0,e.jsx)(n,Object.assign({},h,{children:(0,e.jsx)(p,h)})):p(h)}var f=T;return _(g);})();\n;return Component;"
|
||||
}
|
||||
39
apps/web/src/__generated__/questions/system-design/news-feed-facebook/zh-CN.json
generated
Normal file
39
apps/web/src/__generated__/questions/system-design/news-feed-facebook/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
34
apps/web/src/__generated__/questions/system-design/photo-sharing-instagram/zh-CN.json
generated
Normal file
34
apps/web/src/__generated__/questions/system-design/photo-sharing-instagram/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
34
apps/web/src/__generated__/questions/system-design/rich-text-editor/zh-CN.json
generated
Normal file
34
apps/web/src/__generated__/questions/system-design/rich-text-editor/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
31
apps/web/src/__generated__/questions/system-design/travel-booking-airbnb/zh-CN.json
generated
Normal file
31
apps/web/src/__generated__/questions/system-design/travel-booking-airbnb/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
31
apps/web/src/__generated__/questions/system-design/video-conferencing-zoom/zh-CN.json
generated
Normal file
31
apps/web/src/__generated__/questions/system-design/video-conferencing-zoom/zh-CN.json
generated
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"description": "var Component=(()=>{var m=Object.create;var c=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var x=Object.getPrototypeOf,O=Object.prototype.hasOwnProperty;var j=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports),p=(e,n)=>{for(var o in n)c(e,o,{get:n[o],enumerable:!0})},l=(e,n,o,i)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let r of u(n))!O.call(e,r)&&r!==o&&c(e,r,{get:()=>n[r],enumerable:!(i=d(n,r))||i.enumerable});return e};var _=(e,n,o)=>(o=e!=null?m(x(e)):{},l(n||!e||!e.__esModule?c(o,\"default\",{value:e,enumerable:!0}):o,e)),f=e=>l(c({},\"__esModule\",{value:!0}),e);var h=j((C,s)=>{s.exports=_jsx_runtime});var T={};p(T,{default:()=>M,frontmatter:()=>g});var t=_(h()),g={title:\"\\u89C6\\u9891\\u4F1A\\u8BAE\\uFF08\\u4F8B\\u5982 Zoom\\uFF09\",excerpt:\"\\u8BBE\\u8BA1\\u4E00\\u4E2A\\u7C7B\\u4F3C Zoom \\u548C Google Meet \\u7684\\u89C6\\u9891\\u4F1A\\u8BAE\\u5E94\\u7528\\u7A0B\\u5E8F\"};function a(e){let n=Object.assign({h2:\"h2\",p:\"p\",h3:\"h3\",ul:\"ul\",li:\"li\"},e.components);return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(n.h2,{children:\"\\u95EE\\u9898\"}),`\n`,(0,t.jsx)(n.p,{children:\"TODO\"}),`\n`,(0,t.jsx)(n.h3,{children:\"\\u771F\\u5B9E\\u6848\\u4F8B\"}),`\n`,(0,t.jsxs)(n.ul,{children:[`\n`,(0,t.jsx)(n.li,{children:\"TODO\"}),`\n`,(0,t.jsx)(n.li,{children:\"TODO\"}),`\n`]})]})}function D(e={}){let{wrapper:n}=e.components||{};return n?(0,t.jsx)(n,Object.assign({},e,{children:(0,t.jsx)(a,e)})):a(e)}var M=D;return f(T);})();\n;return Component;",
|
||||
"metadata": {
|
||||
"access": "premium",
|
||||
"author": null,
|
||||
"companies": [],
|
||||
"created": 1630800000,
|
||||
"difficulty": "hard",
|
||||
"duration": 30,
|
||||
"excerpt": "设计一个类似 Zoom 和 Google Meet 的视频会议应用程序",
|
||||
"featured": false,
|
||||
"format": "system-design",
|
||||
"frameworkDefault": null,
|
||||
"frameworks": [],
|
||||
"href": "/questions/system-design/video-conferencing-zoom",
|
||||
"importance": "low",
|
||||
"languages": [],
|
||||
"nextQuestions": [],
|
||||
"published": true,
|
||||
"ranking": 15,
|
||||
"similarQuestions": [],
|
||||
"slug": "video-conferencing-zoom",
|
||||
"subtitle": null,
|
||||
"title": "视频会议(例如 Zoom)",
|
||||
"topics": [
|
||||
"networking",
|
||||
"performance"
|
||||
]
|
||||
},
|
||||
"solution": "var Component=(()=>{var s=Object.create;var i=Object.defineProperty;var o=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var x=Object.getPrototypeOf,m=Object.prototype.hasOwnProperty;var j=(h,n)=>()=>(n||h((n={exports:{}}).exports,n),n.exports),_=(h,n)=>{for(var t in n)i(h,t,{get:n[t],enumerable:!0})},c=(h,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let d of u(n))!m.call(h,d)&&d!==t&&i(h,d,{get:()=>n[d],enumerable:!(r=o(n,d))||r.enumerable});return h};var v=(h,n,t)=>(t=h!=null?s(x(h)):{},c(n||!h||!h.__esModule?i(t,\"default\",{value:h,enumerable:!0}):t,h)),f=h=>c(i({},\"__esModule\",{value:!0}),h);var a=j((A,l)=>{l.exports=_jsx_runtime});var M={};_(M,{default:()=>C,tableOfContents:()=>g});var e=v(a()),g=[{depth:2,value:\"\\u9700\\u6C42\\u63A2\\u7D22\",id:\"\\u9700\\u6C42\\u63A2\\u7D22\"},{depth:2,value:\"\\u67B6\\u6784/\\u9AD8\\u5C42\\u8BBE\\u8BA1\",id:\"\\u67B6\\u6784\\u9AD8\\u5C42\\u8BBE\\u8BA1\"},{depth:2,value:\"\\u6570\\u636E\\u6A21\\u578B\",id:\"\\u6570\\u636E\\u6A21\\u578B\"},{depth:2,value:\"\\u63A5\\u53E3\\u5B9A\\u4E49 (API)\",id:\"\\u63A5\\u53E3\\u5B9A\\u4E49-api\"},{depth:2,value:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",id:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\"},{depth:2,value:\"\\u53C2\\u8003\",id:\"\\u53C2\\u8003\"}];function p(h){let n=Object.assign({h2:\"h2\",p:\"p\",hr:\"hr\"},h.components);return(0,e.jsxs)(e.Fragment,{children:[(0,e.jsx)(n.h2,{id:\"\\u9700\\u6C42\\u63A2\\u7D22\",children:\"\\u9700\\u6C42\\u63A2\\u7D22\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u67B6\\u6784\\u9AD8\\u5C42\\u8BBE\\u8BA1\",children:\"\\u67B6\\u6784/\\u9AD8\\u5C42\\u8BBE\\u8BA1\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u6570\\u636E\\u6A21\\u578B\",children:\"\\u6570\\u636E\\u6A21\\u578B\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u63A5\\u53E3\\u5B9A\\u4E49-api\",children:\"\\u63A5\\u53E3\\u5B9A\\u4E49 (API)\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\",children:\"\\u4F18\\u5316\\u548C\\u6DF1\\u5165\\u7814\\u7A76\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"}),`\n`,(0,e.jsx)(n.hr,{}),`\n`,(0,e.jsx)(n.h2,{id:\"\\u53C2\\u8003\",children:\"\\u53C2\\u8003\"}),`\n`,(0,e.jsx)(n.p,{children:\"\\u5F85\\u529E\\u4E8B\\u9879\"})]})}function b(h={}){let{wrapper:n}=h.components||{};return n?(0,e.jsx)(n,Object.assign({},h,{children:(0,e.jsx)(p,h)})):p(h)}var C=b;return f(M);})();\n;return Component;"
|
||||
}
|
||||
33
apps/web/src/__generated__/questions/system-design/video-streaming-netflix/zh-CN.json
generated
Normal file
33
apps/web/src/__generated__/questions/system-design/video-streaming-netflix/zh-CN.json
generated
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2,14 +2,16 @@
|
|||
"name": "@gfe/questions",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"sync": "npm run sync:algo && npm run sync:js && npm run sync:ui",
|
||||
"sync:rev": "npm run sync:algo:rev && npm run sync:js:rev && npm run sync:ui:rev",
|
||||
"sync": "npm run sync:algo && npm run sync:js && npm run sync:ui && npm run sync:sd",
|
||||
"sync:rev": "npm run sync:algo:rev && npm run sync:js:rev && npm run sync:ui:rev && npm run sync:sd:rev",
|
||||
"sync:algo": "rsync -av --delete ./../../../gfe-questions/packages/questions/algo/ ./algo/",
|
||||
"sync:algo:rev": "rsync -av --delete ./algo/ ./../../../gfe-questions/packages/questions/algo/",
|
||||
"sync:js": "rsync -av --delete ./../../../gfe-questions/packages/questions/javascript/ ./javascript/",
|
||||
"sync:js:rev": "rsync -av --delete ./javascript/ ./../../../gfe-questions/packages/questions/javascript/",
|
||||
"sync:ui": "rsync -av --delete ./../../../gfe-questions/packages/questions/user-interface/ ./user-interface/",
|
||||
"sync:ui:rev": "rsync -av --delete ./user-interface/ ./../../../gfe-questions/packages/questions/user-interface/",
|
||||
"sync:sd": "rsync -av --delete ./../../../gfe-questions/packages/questions/system-design/ ./system-design/",
|
||||
"sync:sd:rev": "rsync -av --delete ./system-design/ ./../../../gfe-questions/packages/questions/system-design/",
|
||||
"test": "jest"
|
||||
},
|
||||
"license": "ISC",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "fd4c00e6",
|
||||
"excerpt": "6f96096c"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"d2fe3f36",
|
||||
"6f97f7ff",
|
||||
"65ea5bcb",
|
||||
"18c9f35",
|
||||
"4ef9a16d",
|
||||
"35ce51c9",
|
||||
"a22b3c77",
|
||||
"960ef338",
|
||||
"59876053"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"d2fe3f36",
|
||||
"6f97f7ff",
|
||||
"65ea5bcb",
|
||||
"18c9f35",
|
||||
"4ef9a16d",
|
||||
"35ce51c9",
|
||||
"a22b3c77",
|
||||
"960ef338",
|
||||
"59876053"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
title: 自动补全
|
||||
excerpt: 设计一个在 Google 和 Facebook 搜索中看到的自动补全组件
|
||||
---
|
||||
|
||||
自动补全是一个常见的问题,许多公司都会问到,它包含许多有用的前端概念和技术,这些概念和技术可以推广到其他前端系统设计问题。强烈建议您好好学习并彻底研究这个问题!
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个自动补全 UI 组件,允许用户在文本框中输入搜索词,在弹出窗口中显示搜索结果列表,用户可以选择一个结果。
|
||||
|
||||
您可能在哪些现实生活中看到过此组件的运行示例:
|
||||
|
||||
* Google 在 google.com 上的搜索栏,您可以在其中看到主要基于文本的建议列表。
|
||||
* Facebook 的搜索输入,您可以在其中看到丰富的搜索结果列表。结果可以是朋友、名人、群组、页面等。
|
||||
|
||||

|
||||
|
||||
提供了一个后端 API,它将根据搜索查询返回结果列表。
|
||||
|
||||
### 要求
|
||||
|
||||
* 该组件足够通用,可供不同的网站使用。
|
||||
* 输入字段 UI 和搜索结果 UI 应该可定制。
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"74734483",
|
||||
"105d5df0",
|
||||
"12020c87",
|
||||
"24febbf2",
|
||||
"98783b14",
|
||||
"c6513c50",
|
||||
"a1a5a469",
|
||||
"2a7816d0",
|
||||
"1776ea1d",
|
||||
"24cfaaad",
|
||||
"fa89b5be",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"ac414b84",
|
||||
"aa52c056",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"41b25248",
|
||||
"c7e15d46",
|
||||
"a0794ba3",
|
||||
"669b9090",
|
||||
"ba03ee4f",
|
||||
"7b75d09c",
|
||||
"4d9ec5ac",
|
||||
"f8f8cf0e",
|
||||
"39702794",
|
||||
"cb72460e",
|
||||
"6ee2f2dd",
|
||||
"7aca8a86",
|
||||
"9f42ebb3",
|
||||
"c634d238",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"3c63f3d0",
|
||||
"eeb2e39c",
|
||||
"44353fd3",
|
||||
"7f440d0d",
|
||||
"94621133",
|
||||
"a5e98aea",
|
||||
"7d3efece",
|
||||
"944079db",
|
||||
"b87b5b23",
|
||||
"fb59b08d",
|
||||
"68c3f136",
|
||||
"f05f89a4",
|
||||
"142ca75f",
|
||||
"f96e7d94",
|
||||
"dfafbe71",
|
||||
"6e8e25f6",
|
||||
"832bbf70",
|
||||
"8e3fae8a",
|
||||
"1f821abc",
|
||||
"e23daac9",
|
||||
"edd1053b",
|
||||
"31ea9d36",
|
||||
"3cfde715",
|
||||
"d2880467",
|
||||
"786b6e68",
|
||||
"5a9309a4",
|
||||
"e45b6678",
|
||||
"771b23e5",
|
||||
"b28d8ad9",
|
||||
"e706bfeb",
|
||||
"3d13484b",
|
||||
"6176e9a6",
|
||||
"a4977e7a",
|
||||
"cd32816a",
|
||||
"f609fce0",
|
||||
"9efb319b",
|
||||
"3ede5e7d",
|
||||
"71757146",
|
||||
"5340d3d0",
|
||||
"e164856a",
|
||||
"94df7e6a",
|
||||
"e3f60e37",
|
||||
"cd90e66a",
|
||||
"d09c9de7",
|
||||
"7ca5f8e8",
|
||||
"9846f084",
|
||||
"efeb82ff",
|
||||
"f29e1845",
|
||||
"7a7ea0c7",
|
||||
"bf2ac03b",
|
||||
"a126e3ce",
|
||||
"7c8c0ad3",
|
||||
"c5e3278f",
|
||||
"55e899c4",
|
||||
"992be4ab",
|
||||
"15fac6e9",
|
||||
"cab1dc44",
|
||||
"afb25429",
|
||||
"ab34bd00",
|
||||
"fcec54cd",
|
||||
"138eb746",
|
||||
"7c7ce11b",
|
||||
"58f8361",
|
||||
"4d9b4b32",
|
||||
"fa822679",
|
||||
"bdbff9b4",
|
||||
"8b864b70",
|
||||
"a4c1df42",
|
||||
"52976047",
|
||||
"74ea299",
|
||||
"edb98584",
|
||||
"d8dca6fc",
|
||||
"b969d0c4",
|
||||
"d1d2e4a3",
|
||||
"1c040f4a",
|
||||
"66605fca",
|
||||
"5ebbb865",
|
||||
"72744531",
|
||||
"52976047",
|
||||
"b41b6cb6",
|
||||
"d19246d1",
|
||||
"44bbb94d",
|
||||
"6a8ec8a5",
|
||||
"bda4eb75",
|
||||
"da7cd6f3",
|
||||
"62fd75b6",
|
||||
"5ab7de30",
|
||||
"97ed4a3a",
|
||||
"2afa81c5"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"74734483",
|
||||
"105d5df0",
|
||||
"12020c87",
|
||||
"24febbf2",
|
||||
"98783b14",
|
||||
"c6513c50",
|
||||
"a1a5a469",
|
||||
"2a7816d0",
|
||||
"1776ea1d",
|
||||
"24cfaaad",
|
||||
"fa89b5be",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"ac414b84",
|
||||
"aa52c056",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"41b25248",
|
||||
"c7e15d46",
|
||||
"a0794ba3",
|
||||
"669b9090",
|
||||
"ba03ee4f",
|
||||
"7b75d09c",
|
||||
"4d9ec5ac",
|
||||
"f8f8cf0e",
|
||||
"39702794",
|
||||
"cb72460e",
|
||||
"6ee2f2dd",
|
||||
"7aca8a86",
|
||||
"9f42ebb3",
|
||||
"c634d238",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"3c63f3d0",
|
||||
"eeb2e39c",
|
||||
"44353fd3",
|
||||
"7f440d0d",
|
||||
"94621133",
|
||||
"a5e98aea",
|
||||
"7d3efece",
|
||||
"944079db",
|
||||
"b87b5b23",
|
||||
"fb59b08d",
|
||||
"68c3f136",
|
||||
"f05f89a4",
|
||||
"142ca75f",
|
||||
"f96e7d94",
|
||||
"dfafbe71",
|
||||
"6e8e25f6",
|
||||
"832bbf70",
|
||||
"8e3fae8a",
|
||||
"1f821abc",
|
||||
"e23daac9",
|
||||
"edd1053b",
|
||||
"31ea9d36",
|
||||
"3cfde715",
|
||||
"d2880467",
|
||||
"786b6e68",
|
||||
"5a9309a4",
|
||||
"e45b6678",
|
||||
"771b23e5",
|
||||
"b28d8ad9",
|
||||
"e706bfeb",
|
||||
"3d13484b",
|
||||
"6176e9a6",
|
||||
"a4977e7a",
|
||||
"cd32816a",
|
||||
"f609fce0",
|
||||
"9efb319b",
|
||||
"3ede5e7d",
|
||||
"71757146",
|
||||
"5340d3d0",
|
||||
"e164856a",
|
||||
"94df7e6a",
|
||||
"e3f60e37",
|
||||
"cd90e66a",
|
||||
"d09c9de7",
|
||||
"7ca5f8e8",
|
||||
"9846f084",
|
||||
"efeb82ff",
|
||||
"f29e1845",
|
||||
"7a7ea0c7",
|
||||
"bf2ac03b",
|
||||
"a126e3ce",
|
||||
"7c8c0ad3",
|
||||
"c5e3278f",
|
||||
"55e899c4",
|
||||
"992be4ab",
|
||||
"15fac6e9",
|
||||
"cab1dc44",
|
||||
"afb25429",
|
||||
"ab34bd00",
|
||||
"fcec54cd",
|
||||
"138eb746",
|
||||
"7c7ce11b",
|
||||
"58f8361",
|
||||
"4d9b4b32",
|
||||
"fa822679",
|
||||
"bdbff9b4",
|
||||
"8b864b70",
|
||||
"a4c1df42",
|
||||
"52976047",
|
||||
"74ea299",
|
||||
"edb98584",
|
||||
"d8dca6fc",
|
||||
"b969d0c4",
|
||||
"d1d2e4a3",
|
||||
"1c040f4a",
|
||||
"66605fca",
|
||||
"5ebbb865",
|
||||
"72744531",
|
||||
"52976047",
|
||||
"b41b6cb6",
|
||||
"d19246d1",
|
||||
"44bbb94d",
|
||||
"6a8ec8a5",
|
||||
"bda4eb75",
|
||||
"da7cd6f3",
|
||||
"62fd75b6",
|
||||
"5ab7de30",
|
||||
"97ed4a3a",
|
||||
"2afa81c5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
## 需求探索
|
||||
|
||||
这些是您应该向面试官提出的问题,以便更深入地研究问题并完善需求。
|
||||
|
||||
### 应该支持什么样的结果?
|
||||
|
||||
文本、图像、媒体(带有文本的图像)是最常见的结果类型,但我们无法预测组件用户将要呈现的所有不同类型的结果。
|
||||
|
||||
### 这个组件将在哪些设备上使用?
|
||||
|
||||
所有可能的设备:笔记本电脑、平板电脑、手机等。
|
||||
|
||||
### 我们是否需要支持模糊搜索?
|
||||
|
||||
不适用于初始版本。 如果我们有时间,我们可以探索这个。
|
||||
|
||||
***
|
||||
|
||||
## 架构
|
||||
|
||||

|
||||
|
||||
* **输入字段 UI**
|
||||
* 处理用户输入并将用户输入传递给控制器。
|
||||
* **结果 UI(弹出窗口)**
|
||||
* 从控制器接收结果并将其呈现给用户。
|
||||
* 处理用户选择并通知控制器选择了哪个输入。
|
||||
* **缓存**
|
||||
* 存储先前查询的结果,以便控制器可以检查是否向服务器发送请求。
|
||||
* **控制器**
|
||||
* 整个组件的“大脑”,类似于模型视图控制器 (MVC) 模式中的控制器。 系统中的所有组件都与此组件交互。
|
||||
* 在组件之间传递用户输入和结果。
|
||||
* 如果特定查询的缓存为空,则从服务器获取结果。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
* 控制器
|
||||
* 通过组件 API 公开的 Props/options
|
||||
* 当前搜索字符串
|
||||
* 缓存
|
||||
* 初始结果
|
||||
* 缓存结果
|
||||
* 请参阅下面的缓存数据模型设计部分
|
||||
|
||||
*这些只是基本功能所需的核心字段。 随着我们深入研究下面的特定主题,将添加更多字段。*
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
由于这是一个前端系统设计问题,我们将重点关注组件的 API,并仅简要介绍服务器应提供的搜索 API。
|
||||
|
||||
### 客户端
|
||||
|
||||
由于我们希望创建一个灵活且易于其他开发人员使用的组件,因此我们不能对组件的使用方式做出太多假设,并且必须提供大量选项。
|
||||
|
||||
#### 基本 API
|
||||
|
||||
这些是影响组件功能的关键 API。
|
||||
|
||||
* **结果数量**:要在结果列表中显示的结果数量。
|
||||
* **API URL**:进行查询时要访问的 URL。对于自动完成用例,查询是在用户输入时进行的。
|
||||
* **事件监听器**:`'input'`、`'focus'`、`'blur'`、`'change'`、`'select'` 是开发人员可能希望响应的一些常见事件(可能记录用户交互),因此为这些事件添加钩子会很有帮助。
|
||||
* **自定义渲染**:有几种方法可以允许开发人员自定义其 UI 各个部分的渲染以满足其用例:
|
||||
* **主题选项对象**:这种方法最容易使用,但灵活性/可定制性最差。组件可以接受一个键/值对象(例如 `{ textSize: 12px, textColor: 'red' }`),并在渲染时使用它。
|
||||
* **类名**:允许开发人员指定自己的 CSS 类名,组件将把这些类名添加到各种 UI 子组件中。
|
||||
* **渲染函数/回调**:这是一种常用于 React 的控制反转技术,其中渲染完全留给开发人员。组件使用一些数据调用开发人员提供的函数,开发人员可以根据该数据自定义逻辑/代码以渲染 UI。这是最灵活的方法,但需要开发人员付出最大的努力。
|
||||
|
||||
{/* TODO 添加更多关于渲染函数的阅读 */}
|
||||
|
||||
#### 高级 API
|
||||
|
||||
如果时间允许,这些 API 会影响组件的用户体验和性能,应该涵盖。
|
||||
|
||||
* **最小查询长度**:如果用户查询太短,由于不够具体,可能会出现太多不相关的结果。我们可能只想在输入最少数量的字符(可能 3 个或更多)时才触发搜索。
|
||||
* **防抖动持续时间**:对每次按键都触发后端搜索 API 可能会造成浪费,尤其是在前几个字符的查询可能没有意义时。防抖动是一种限制函数被调用次数的技术。我们可以对 API 的访问进行防抖动,这样服务器就不会被访问得太频繁。防抖动持续时间为 300 毫秒时,后端搜索 API 将仅在 300 毫秒内没有用户输入后才会被调用。
|
||||
* **API 超时持续时间**:我们应该等待多长时间才能确定搜索已超时,并且我们可以显示错误。
|
||||
* **与缓存相关**:有关这些选项的更多详细信息将在下面的缓存部分中介绍。
|
||||
* 初始结果
|
||||
* 结果来源:仅限网络/网络和缓存/仅限缓存
|
||||
* 用于合并来自服务器和缓存的结果的函数
|
||||
* 缓存持续时间
|
||||
|
||||
### 服务器 API
|
||||
|
||||
服务器应提供支持以下参数的 HTTP API:
|
||||
|
||||
* `query`:实际搜索查询
|
||||
* `limit`:一页中的结果数
|
||||
* `pagination`:页码
|
||||
|
||||
`pagination` 和 `limit` 在需要允许用户向下滚动超出初始结果列表以获取下一“页”结果时很有用。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
在解决了基础知识之后,我们可以更深入地研究与该问题相关的特定主题。
|
||||
|
||||
### 网络
|
||||
|
||||
#### 处理并发请求/竞态条件
|
||||
|
||||
如果用户在有待处理的网络请求时更改了查询,会发生什么情况?如果有多个待处理的网络请求,我们需要注意不要显示先前搜索查询的结果。我们不能依赖服务器的网络响应的返回顺序,因为较早的请求可能仅在稍后才完成,而稍后的请求则在稍后才完成。
|
||||
|
||||
{/* TODO: 插入网络请求竞态条件的图 */}
|
||||
|
||||
要知道我们应该使用哪个请求的响应来显示,我们可以:
|
||||
|
||||
1. 为每个请求附加一个时间戳,以确定最新请求,并且仅显示最新请求的结果(不是最新响应!)。丢弃无关查询的响应。
|
||||
2. 将结果保存在一个对象/映射中,以搜索查询字符串为键,并且仅显示与搜索输入中的输入值相对应的结果。
|
||||
|
||||
哪个选项更好? 鉴于我们有一个缓存,可以记住每个搜索查询的响应,选项 2 显然更好。 有关更多详细信息,请参阅缓存部分详细信息。
|
||||
|
||||
不建议中止请求(通过`AbortController`)或丢弃响应,因为服务器将收到并处理该请求,并将数据返回给客户端。
|
||||
|
||||
保存历史击键的响应对于用户意外键入额外字符的情况很有用。“f' -> “fo” -> “foo” -> 意思是输入“t”,但由于手指肥胖而误键入了额外的“r” -> “foot” -> “footr” -> 删除额外的“r” -> “foot”(“foot”的结果可以立即显示,因为它已经在缓存中)。 如果有防抖,那么“foot”的请求可能没有立即触发,并且没有“foot”的响应可以缓存,所以这主要有利于没有防抖的自动完成组件或打字速度慢于防抖持续时间的人。
|
||||
|
||||
#### 失败的请求和重试
|
||||
|
||||
服务器请求有时可能会失败,这可能是由于用户的互联网连接不稳定。 该组件可以自动重试触发查询。 如果服务器确实离线,并且我们担心服务器过载,我们可以使用指数退避策略。
|
||||
|
||||
#### 离线使用
|
||||
|
||||
如果我们检测到设备已完全失去网络连接,那么我们无能为力,因为我们的组件依赖于服务器获取数据。 但我们可以执行以下操作来改善用户体验:
|
||||
|
||||
* 纯粹从缓存中读取。 显然,如果缓存为空,这并没有什么用处。
|
||||
* 不触发任何请求,以免浪费 CPU 周期。
|
||||
* 在组件中的某处指示没有网络连接。
|
||||
|
||||
### 缓存
|
||||
|
||||
缓存是做什么用的? 缓存通常用于提高查询的性能,并通过将先前查询的结果保存在内存中来降低处理成本。 如果/当用户再次搜索相同的术语时,我们可以从内存中检索结果并立即显示结果,而不是点击服务器获取结果,从而有效地消除了对任何网络请求和延迟的需求。
|
||||
|
||||
为了提供最佳体验,Google 和 Facebook 搜索输入会缓存用户查询。
|
||||
|
||||
#### 缓存结构
|
||||
|
||||
组件内的缓存是该组件最有趣的一个方面,因为有许多设计缓存的方法,每种方法都有其自身的优缺点。 解释每种方法的权衡对于在前端系统设计面试中取得好成绩至关重要。
|
||||
|
||||
**1. 以搜索查询为键,结果为值的哈希映射。** 这是缓存最明显的结构,将字符串查询映射到结果。 检索结果非常简单,只需查找缓存是否包含搜索词作为键,即可在 O(1) 时间内获得结果。
|
||||
|
||||
```js
|
||||
const cache = {
|
||||
fa: [
|
||||
{ type: 'organization', text: 'Facebook' },
|
||||
{
|
||||
type: 'organization',
|
||||
text: 'FasTrak',
|
||||
subtitle: 'Government office, San Francisco, CA',
|
||||
},
|
||||
{ type: 'text', text: 'face' },
|
||||
],
|
||||
fac: [
|
||||
{ type: 'organization', text: 'Facebook' },
|
||||
{ type: 'text', text: 'face' },
|
||||
{ type: 'text', text: 'facebook messenger' },
|
||||
],
|
||||
face: [
|
||||
{ type: 'organization', text: 'Facebook' },
|
||||
{ type: 'text', text: 'face' },
|
||||
{ type: 'text', text: 'facebook stock' },
|
||||
],
|
||||
faces: [
|
||||
{ type: 'television', text: 'Faces of COVID', subtitle: 'TV program' },
|
||||
{ type: 'musician', text: 'Faces', subtitle: 'Rock band' },
|
||||
{ type: 'television', text: 'Faces of Death', subtitle: 'Film series' },
|
||||
],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
但是,请注意,尤其是在我们没有进行任何防抖的情况下,存在大量重复的结果,因为用户正在键入,并且我们为每次击键触发一个请求。 这会导致页面消耗大量内存用于缓存。
|
||||
|
||||
**2. 结果列表。** 或者,我们可以将结果保存为平面列表,并在前端进行自己的过滤。 结果不会有太多(如果有的话)重复。
|
||||
|
||||
```js
|
||||
const results = [
|
||||
{ type: 'company', text: 'Facebook' },
|
||||
{
|
||||
type: 'organization',
|
||||
text: 'FasTrak',
|
||||
subtitle: 'Government office, San Francisco, CA',
|
||||
},
|
||||
{ type: 'text', text: 'face' },
|
||||
{ type: 'text', text: 'facebook messenger' },
|
||||
{ type: 'text', text: 'facebook stock' },
|
||||
{ type: 'television', text: 'Faces of COVID', subtitle: 'TV program' },
|
||||
{ type: 'musician', text: 'Faces', subtitle: 'Rock band' },
|
||||
{ type: 'television', text: 'Faces of Death', subtitle: 'Film series' },
|
||||
];
|
||||
```
|
||||
|
||||
但是,这在实践中并不理想,因为我们必须在客户端进行过滤。 这对性能不利,并且最终可能会阻塞 UI 线程,尤其是在大型数据集和慢速设备上。 每个结果的排名顺序也可能丢失,这并不理想。
|
||||
|
||||
**3. 结果的规范化映射。** 我们从 [normalizr](https://github.com/paularmstrong/normalizr/tree/master/docs) 获得灵感,并将缓存结构化为数据库,结合了先前方法的最佳特性——快速查找和非重复数据。 每个结果条目都是“数据库”中的一行,并由唯一的 ID 标识。 缓存只是引用每个项目的 ID。
|
||||
|
||||
```js
|
||||
// 通过 ID 存储结果,以便轻松检索特定 ID 的数据。
|
||||
const results = {
|
||||
1: { id: 1, type: 'organization', text: 'Facebook' },
|
||||
2: {
|
||||
id: 2,
|
||||
type: 'organization',
|
||||
text: 'FasTrak',
|
||||
subtitle: 'Government office, San Francisco, CA',
|
||||
},
|
||||
3: { id: 3, type: 'text', text: 'face' },
|
||||
4: { id: 4, type: 'text', text: 'facebook messenger' },
|
||||
5: { id: 5, type: 'text', text: 'facebook stock' },
|
||||
6: {
|
||||
id: 6,
|
||||
type: 'television',
|
||||
text: 'Faces of COVID',
|
||||
subtitle: 'TV program',
|
||||
},
|
||||
7: { id: 7, type: 'musician', text: 'Faces', subtitle: 'Rock band' },
|
||||
8: {
|
||||
id: 8,
|
||||
type: 'television',
|
||||
text: 'Faces of Death',
|
||||
subtitle: 'Film series',
|
||||
},
|
||||
};
|
||||
|
||||
const cache = {
|
||||
fa: [1, 2, 3],
|
||||
fac: [1, 3, 4],
|
||||
face: [1, 3, 5],
|
||||
faces: [6, 7, 8],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
在向用户显示结果之前,需要进行预处理,将列表的结果 ID 映射到实际的结果项,但如果只显示几个项目,则处理成本可以忽略不计。
|
||||
|
||||
**使用哪种结构?**
|
||||
|
||||
使用哪种方法取决于此组件所使用的应用程序类型。
|
||||
|
||||
* **短寿命网站**:如果该组件用于短寿命页面(例如 Google 搜索),则选项 1 是最佳选择。 即使存在重复数据,用户也不太可能经常使用搜索,因此内存使用量不会成为问题。 无论如何,当用户点击搜索结果时,缓存将被清除/重置。
|
||||
* **长寿命网站**:如果此自动完成组件用于长寿命单页应用程序(例如 Facebook 网站),则选项 3 可能是可行的。 但是,还要注意,将结果缓存太久可能不是一个好主意,因为过时的结果会占用内存而没有用。
|
||||
|
||||
#### 初始结果
|
||||
|
||||
注意到在 Google 搜索中,当您第一次关注输入时,即使您还没有输入任何内容,也会显示结果列表? 显示初始的相关结果列表可能有助于节省用户输入并降低服务器成本。
|
||||
|
||||
* **Google**:今天的热门搜索查询(时事、热门名人、最新动态)和历史搜索
|
||||
* **Facebook**:历史搜索。
|
||||
* **股票/加密货币/货币交易所**:历史搜索或热门股票
|
||||
|
||||
初始结果可以是组件上的一个选项,并添加到缓存中,其中键是空字符串。
|
||||
|
||||
过去,Facebook 将用户的 Friends、Pages、Groups 加载到浏览器缓存中,以便可以通过客户端过滤即时显示结果,而无需发送另一个 HTTP 请求。
|
||||
|
||||
*来源:[The Life of a Typeahead Query](https://engineering.fb.com/2010/05/17/web/the-life-of-a-typeahead-query/)*
|
||||
|
||||
{/* TODO: 讨论从服务器和缓存合并结果的功能 */}
|
||||
|
||||
#### 缓存策略
|
||||
|
||||
缓存是一种空间/时间权衡,我们用内存空间来节省处理时间。 将缓存结果保留太久是一个坏主意,因为它会消耗内存,并且如果自写入缓存条目以来已经过去了很长时间,则结果可能无关紧要/过时。 使用内存来存储无关紧要/过时的结果几乎没有价值。
|
||||
|
||||
何时清除缓存取决于应用程序的类型:
|
||||
|
||||
* **Google**:Google 搜索结果不会经常更新,因此缓存很有用,并且可以持续很长时间(数小时?)。
|
||||
* **Facebook**:Facebook 搜索结果会适度更新,因此缓存很有用,但应不时清除条目(半小时?)。
|
||||
* **股票/货币交易所**:交易所的自动完成功能,用于显示股票代码/货币,在结果中显示当前价格,可能根本不想缓存,因为价格会在市场开放时每分钟变化。
|
||||
|
||||
我们可以在组件上添加数据源/缓存策略和缓存持续时间作为配置选项。
|
||||
|
||||
* **数据源**:是仅从“网络”、“网络和缓存”、“仅缓存”读取结果。
|
||||
* **缓存持续时间/生存时间**:保留每个缓存条目的时间。 这将涉及向每个条目添加时间戳,并时不时地清除过时的缓存条目。
|
||||
|
||||
### 性能
|
||||
|
||||
这里的性能是指客户端性能,因为服务器端性能(查询返回的速度)超出了范围。
|
||||
|
||||
#### 加载速度
|
||||
|
||||
我们无法提高服务器返回响应的速度,但通过客户端缓存,我们可以近乎即时地显示先前查询的结果。如果匹配,我们甚至可以更进一步,将缓存的结果用于未来的结果。
|
||||
|
||||
#### 防抖/节流
|
||||
|
||||
通过限制可以触发的网络请求数量,我们减少了服务器负载和 CPU 处理。
|
||||
|
||||
#### 内存使用
|
||||
|
||||
长期存在的页面可能具有自动完成组件,这些组件会在缓存中累积太多结果并占用内存。对于此类页面,清除缓存和释放内存至关重要。清除可以在浏览器空闲或总内存/缓存条目数超过某个阈值时完成。
|
||||
|
||||
#### 虚拟化列表
|
||||
|
||||
如果结果包含许多项目(数百到数千个),在浏览器中渲染这么多 DOM 节点会消耗大量内存并降低浏览器速度。列表虚拟化是我们可以使用的一种技术,它允许组件保持其性能。
|
||||
|
||||
来自 [https://web.dev](https://web.dev/virtualize-long-lists-react-window):
|
||||
|
||||
> 列表虚拟化或“窗口化”仅渲染用户可见的内容的概念。最初渲染的元素数量是整个列表的一个很小的子集,当用户继续滚动时,可见内容的“窗口”会移动。这提高了列表的渲染和滚动性能。
|
||||
|
||||
这里的技巧是仅渲染可见的节点并回收 DOM 节点,而不是创建新的节点。我们可以使结果窗口产生包含许多结果的假象,使用假的屏幕外元素来增加非可见结果元素的高度。
|
||||
|
||||
### 用户体验
|
||||
|
||||
以下是将一些良好的 UX 实践应用于自动完成组件的示例:
|
||||
|
||||
#### 自动对焦
|
||||
|
||||
如果它是一个搜索页面(如 Google),并且您非常确定用户在屏幕上出现自动完成时有很高的使用意图,请将 `autofocus` 属性添加到您的 `input` 中。
|
||||
|
||||
#### 处理不同的状态
|
||||
|
||||
* **加载中**:当有后台请求时,显示微调器。
|
||||
* **错误**:显示带有重试请求按钮的错误消息。
|
||||
* **无网络**:显示没有可用网络的错误消息。
|
||||
|
||||
#### 处理长字符串
|
||||
|
||||
结果项中的长文本应适当处理,通常通过使用省略号截断或进行良好换行。文本不应溢出并出现在组件外部。
|
||||
|
||||
#### 移动友好性
|
||||
|
||||
* 如果在移动设备上使用,每个结果项都应该足够大,以便用户点击。
|
||||
* 结果的数量取决于视口窗口大小,但最好在用户端实现。
|
||||
* 为移动设备设置有用的属性:`autocapitalize="off"`,`autocomplete="off"`,`autocorrect="off"`,`spellcheck="false"`,这样浏览器建议就不会干扰用户的搜索。
|
||||
|
||||
#### 键盘交互
|
||||
|
||||
* 用户应该能够仅通过键盘使用该组件并专注于自动完成建议。在**辅助功能**部分阅读更多内容。
|
||||
* 添加一个全局快捷键,让用户可以轻松地专注于自动完成输入。一个常见的键盘快捷键是<kbd>/</kbd>(正斜杠)键,Facebook、X 和 YouTube 都在使用它。
|
||||
|
||||
#### 搜索中的拼写错误
|
||||
|
||||
很容易出现拼写错误,尤其是在移动设备上。模糊搜索是一种搜索技术,它会考虑与搜索查询非常接近而不是完全匹配的结果。即使搜索词拼写错误,模糊搜索也能帮助您找到相关结果。
|
||||
|
||||
如果过滤完全在客户端完成,则可以使用模糊搜索,方法是计算搜索查询和结果之间的编辑距离(例如,[Levenshtein 距离](https://en.wikipedia.org/wiki/Levenshtein_distance)),并选择编辑距离最小的那些。对于在服务器端完成的搜索,我们可以按原样发送查询,并在服务器上完成模糊匹配。
|
||||
|
||||
#### 查询结果定位
|
||||
|
||||
自动完成建议列表通常显示在输入下方。但是,如果自动完成组件位于窗口底部,则没有足够的空间来完全显示结果。建议可以意识到其在页面上的位置,如果下方没有空间显示,则可以在输入上方呈现。
|
||||
|
||||
### 辅助功能
|
||||
|
||||
#### 屏幕阅读器
|
||||
|
||||
* 使用语义 HTML 或使用正确的 `aria` 角色(如果使用非语义 HTML)。使用 `<ul>`、`<li>` 构建列表项或 `role="listbox"` 和 `role="option"`。
|
||||
* `<input>` 的 `aria-label`,因为通常没有可见标签。
|
||||
* `<input>` 的 `role="combobox"`。
|
||||
* [`aria-haspopup`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-haspopup) 指示该元素可以触发交互式弹出元素。
|
||||
* [`aria-expanded`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded) 指示当前是否显示弹出元素。
|
||||
* 使用 [`aria-live`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) 标记结果区域,以便在显示新结果时,屏幕阅读器用户收到通知。
|
||||
* [`aria-autocomplete`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-autocomplete) 描述当 combobox 动态帮助用户完成文本输入时,combobox 将使用的自动完成交互模型类型,无论建议将以内联的单个值显示 (`aria-autocomplete="inline"`) 还是在值集合中显示 (`aria-autocomplete="list"`)
|
||||
* Google 使用 `aria-autocomplete="both"`,而 Facebook 和 X 使用 `aria-autocomplete="list"`。
|
||||
|
||||
#### 键盘交互
|
||||
|
||||
* 按 Enter 执行搜索。您可以通过将`<input>`包装在`<form>`中免费获得此行为。
|
||||
* 使用向上/向下箭头导航选项,当到达列表末尾时环绕。
|
||||
* 按 Escape 键关闭结果弹出窗口(如果可见)。
|
||||
* ... 还有更多。 遵循 [WAI ARIA 组合框](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) 实践。
|
||||
|
||||
## 比较 Google、Facebook 和 X 搜索组件
|
||||
|
||||
以下是 Google、Facebook 和 X 的搜索自动完成组件以及使用的 HTML 属性的比较。
|
||||
|
||||
| HTML 属性 | Google | Facebook | X |
|
||||
| --- | --- | --- | --- |
|
||||
| HTML 元素 | `<textarea>` | `<input>` | `<input>` |
|
||||
| 在`<form>`中 | 是 | 否 | 是 |
|
||||
| `type` | `"text"` | `"search"` | `"text"` |
|
||||
| `autocapitalize` | `"off"` | 缺席 | `"sentence"` |
|
||||
| `autocomplete` | `"off"` | `"off"` | `"off"` |
|
||||
| `autocorrect` | `"off"` | 缺席 | `"off"` |
|
||||
| `autofocus` | 存在 | 缺席 | 存在 |
|
||||
| `placeholder` | 缺席 | `"Search Facebook"` | `"Search"` |
|
||||
| `role` | `"combobox"` | 缺席 | `"combobox"` |
|
||||
| `spellcheck` | `"false"` | `"false"` | `"false"` |
|
||||
| `aria-activedescendant` | 存在 | 缺席 | 存在 |
|
||||
| `aria-autocomplete` | `"both"` | `"list"` | `"list"` |
|
||||
| `aria-expanded` | 存在 | 存在 | 存在 |
|
||||
| `aria-haspopup` | `"false"` | 缺席 | 缺席 |
|
||||
| `aria-invalid` | 缺席 | `"false"` | 缺席 |
|
||||
| `aria-label` | `"Search"` | `"Search Facebook"` | `"Search query"` |
|
||||
| `aria-owns` | 存在 | 缺席 | 存在 |
|
||||
| `dir` | 缺席 | `"ltr"`/`"rtl"` | `"auto"` |
|
||||
| `enterkeyhint` | 缺席 | 缺席 | `"search"` |
|
||||
|
||||
注意:此表仅在撰写本文时是准确的,因为公司会不时更新其搜索组件。 重点是证明在要使用的 ARIA 属性方面没有标准化的做法。
|
||||
|
||||
{/* TODO: 谈论测试 */} {/* TODO: 也许提到 Tries? */}
|
||||
|
||||
## 参考
|
||||
|
||||
* [Typeahead 查询的生命周期](https://engineering.fb.com/2010/05/17/web/the-life-of-a-typeahead-query/)
|
||||
* [构建可访问的自动完成控件](https://adamsilver.io/blog/building-an-accessible-autocomplete-control/)
|
||||
|
||||
## 变更日志
|
||||
|
||||
* 2024/08/21
|
||||
* 更新了 Google、Facebook、X 的 HTML 属性表。
|
||||
* 将 Twitter 更改为 X。
|
||||
* 2023/01/14
|
||||
* 将规范化存储格式更新为对象而不是数组,以便快速查找。
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "898fca84",
|
||||
"excerpt": "5658bc24"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"a2d8d6f2",
|
||||
"a8f0892",
|
||||
"9ec6d64c",
|
||||
"58ef341b"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"a2d8d6f2",
|
||||
"a8f0892",
|
||||
"9ec6d64c",
|
||||
"58ef341b"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
title: 聊天应用(例如 Messenger)
|
||||
excerpt: 设计一个类似 Messenger 和 Slack 的聊天应用程序
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个允许用户互相发送消息的聊天应用程序。
|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* [Messenger](https://www.messenger.com)
|
||||
* [WhatsApp Web](https://www.whatsapp.com)
|
||||
* [Slack](https://www.slack.com)
|
||||
* [Discord](https://www.discord.com)
|
||||
* [Telegram](https://www.telegram.org)
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
In the general case features own SQLite tables that they use to store their data. For instance, the "message reactions" feature might own a reactions table that they use to store the reactions that people have given to specific messages. This data is synced from the Messenger Infrastructure backend and it is presented to the user when he or she opens a conversation. The syncing logic will write data to the reactions table and the UI layer will read from it to present it to the user in a meaningful way. In msys even the operations that are sent to the server, such as when you react to a message, are first inserted into the database before they are sent over the network.
|
||||
|
||||
our UI code shouldn't be doing any complicated logic, it should in general just display what is in the database. Focus your schema design on making sure that the view has minimal code.
|
||||
|
||||
DataScript also offers type-safe integration with the task framework. In msys a task is how the client sends data to the server (roughly equivalent to running an HTTP request, but more sophisiticated). For instance, when a Messenger user sends a message to someone there is some DataScript program invoked from the client that takes care of inserting an optimistic message in the database (so that the message can be displayed on the UI right away) and also sending an msys task to the server with the content of the message. Upon receiving the task the server will interact with the Messenger Infrastructure to deliver the message to the recipient and it will also send back a response to the original sender to indicate the message was processed.
|
||||
|
||||
In general your goal when writing sync logic should be to keep track of what data has changed since you last checked and then send down DataScript code to mutate the database to keep your client back up to date.
|
||||
|
||||
## msys Tasks Framework
|
||||
|
||||
The major players are:
|
||||
|
||||
1. Stored Procedure - The sproc that inserts a new task into the task queue, the "pending_tasks" table (action A). Invoking this sproc from your product logic will kick off this process. At the same time, as a side-effect, this might perform some sort of optimistic write. It doesn't have to, but if you need an optimistic write, this is probably the place to do it.
|
||||
|
||||
2. Task Queue - The task queue will send your task to the server over HTTP or MQTT (action B). Tasks have a concept of a queue name and task id. If multiple tasks have the same queue name, they will be sent to the server one-by-one and the next will not be sent until the previous response is received. The queue also supports batching.
|
||||
|
||||
3. Task Handler - You write one of these in hack. This acts sort of like an XController. This is the meat-and-potatoes of the operation, and the glue between LightSpeed and the rest of Facebook's infra. Whether it's sending a message, a reaction, adding a participant to a thread, or whatever it is, this is where we actually do that. Like all things, this can either succeed or fail for any reason.
|
||||
|
||||
4. Task Response - There are three types of things we can send back in response to a task request: Success Ops This is DASM that we send to indicate to the client, "Your request succeeded, and as a consequence, do this thing." This would also be the place that you'd make an optimistic write Authoritative if you performed one earlier. Failure OPs This is DASM that we send to indicate, "Your request failed permanently, you should do this to recover/clean up what you did before." If you did an optimistic write in step 1, this is where you'd tidy it up. A retryable Exception Any exception is always retried by the client. The client performs exponential backoff, but as of this writing will retry forever, so you have to be careful about what sort of exceptions you send back to the client, because if it's something that's actually a permanent failure, the client's task queue will be blocked from ever advancing.
|
||||
|
||||
## Client Side Task Processing
|
||||
|
||||
Tasks from a queue are taken in order to send to the server. Tasks currently being processed and have not received a response from the server are marked as in-progress, and no more tasks from the same queue will be processed until the in-progress task(s) return a result.
|
||||
|
||||
### CLIENT BATCHING
|
||||
|
||||
Up to 5 tasks in the same queue may be batched and sent to the server in a single request. Tasks with different labels may be included in the same batch, and all the tasks will stay in order. This optimization reduces the number of network calls and saves on payload size overhead.
|
||||
|
||||
## What are Optimistic Writes?
|
||||
|
||||
Optimistic writes allow us to write things to the database locally in response to some action by a user, so that we can drive a UI update. When we get confirmation from the server that whatever we asked it to do is done, and the data that the user caused to be written is good to go, we mark it as "Authoritative". If the write fails we can remove the data or otherwise tidy up so things aren't inconsistent with the server.
|
||||
|
||||
This is probably best shown with an example. Suppose you're sending a reaction. The simplest thing we could possibly do is send up the request to add the reaction and then wait for the resulting Delta to come all the way back around from Iris to tell the client to add the reaction. That means that, in the very best case scenario, there's about a one-second round trip all the way through Messaging Infra, so the user will have to wait a whole second for the UI to update, which is a pretty poor user experience. And that's on a good day when the user is in the US and nothing got lost. If the user is in a developing country and something gets lost in the network then the user may never see the updated UI and has an even worse experience. Instead, we immediately add the reaction locally, then if we get an indication that the request to the server succeeded, the client cements the reaction by marking it as "Authoritative". If the server indicates failure, we remove the reaction.
|
||||
|
||||
Datascript has built-in support for making optimistic writes easier, and for making sure that we don't accidentally overwrite an authoritative row with a non-authoritative one with features like$table->updateRowIfAuthorityLessThanOrEq(), which makes sure you don't have to re-implement the authority-checking logic yourself, and encapsulates this inside Datascript itself.
|
||||
|
||||
To be fully clear - this AUTHORITY column is used to track the authority level of the entire row to help with collisions across client/server. As the client updates a single column on an existing row, the authority level of the row should not be lowered. We don't have a good way right now to track optimistic writes for column, and we require the sync stream to fully reconcile the final state of the world for optimistic write task success/failure. This means that if a task fails, we need to tell the sync stream to reload the data that was being updated (such as thread name, adding participants, etc), and we do not yet track safe rollbacks to be driven by the client.
|
||||
|
||||
1. Lightspeed clients work by having the server push down data that you need into a database. The server also keeps the database in sync
|
||||
2. The UI for each client reads from the db instead of a GQL query
|
||||
3. If you need to perform an action (send a message / fetch more data), you insert a task into the database instead of issuing a GQL mutation. The task is then sent to the server on your behalf. Responses will be synced to the database, you do not wait for a response
|
||||
|
||||
- Sync procedure is called when page loads
|
||||
- Whenever our realtime connections (MQTT/DGW) reconnect
|
||||
|
||||
https://excalidraw.com/#json=vIOFh1vzI6P0C0A0JLRAa,1ZikF0vdllVI0N1Lk8xjaQ
|
||||
|
||||
## Task System
|
||||
|
||||
https://excalidraw.com/#json=1Qx2r-OiI8FPeZAt0K3SI,Jze4BoiHv30_V0xJ01opvw
|
||||
|
||||
## Lightspeed
|
||||
|
||||
Lightspeed built it's own system for sending things to the server (task system). This is needed because the app needs to work while offline, and normal network requests (a.k.a await fetch(...)) don't have good support for this.
|
||||
|
||||
For example, if your app goes offline, we still want to guarantee that the message gets sent to the server.
|
||||
|
||||
As such, the tasks system was built, and currently supports the following features:
|
||||
|
||||
- Strict ordering
|
||||
- Batching
|
||||
- Cancellation
|
||||
- Retries
|
||||
- Exponential Backoff
|
||||
- Resume from network failure
|
||||
|
||||
https://excalidraw.com/#json=ly0Y4wqWamic47Qfrk_CI,i14HM2cG2L1OjGNwB70vEA
|
||||
|
||||
https://excalidraw.com/#json=y9v4XCUNjVECq_PKwxUiC,7P9M4RsvWmLRKhiFqpzQMg
|
||||
|
||||
## Gradient effect
|
||||
|
||||
https://css-tricks.com/recreating-the-facebook-messenger-gradient-effect-with-css/
|
||||
|
||||
{/_ TODO: https://excalidraw.com/#json=5713648060203008,7R5UAo9i5pRTISOZviieLQ _/} {/_ TODO: https://excalidraw.com/#json=5128115764330496,7vBP9AmfOuwT1hLYz9B4Rw _/} {/_ TODO: https://excalidraw.com/#json=5172883747766272,wuH2YFQXXZCPfzuSJWhmEg _/}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"a287c41b",
|
||||
"ab4d2319",
|
||||
"11850d2f",
|
||||
"6a5201ad",
|
||||
"41949105",
|
||||
"4ac88698",
|
||||
"c5afbfaa",
|
||||
"1424daf2",
|
||||
"14166273",
|
||||
"799a7e6f",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"ecc890b3",
|
||||
"4dbdfd86",
|
||||
"62c8318",
|
||||
"76c51821",
|
||||
"5b2c7887",
|
||||
"536e0d6",
|
||||
"6109061f",
|
||||
"cbdfd012",
|
||||
"a77974e1",
|
||||
"d2181ece",
|
||||
"1f1a9a1b",
|
||||
"e9ac5ae5",
|
||||
"725d11ea",
|
||||
"4fc75eed",
|
||||
"19b5ce10",
|
||||
"f74f0d0d",
|
||||
"70d465c7",
|
||||
"e8ee6814",
|
||||
"f86cae5e",
|
||||
"a4d30dab",
|
||||
"f041ab3c",
|
||||
"9fab7823",
|
||||
"2c06980d",
|
||||
"83dfe3f",
|
||||
"55f53409",
|
||||
"6bb51a4",
|
||||
"5db348d1",
|
||||
"17476c2e",
|
||||
"a89b7c71",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"27e4dbb6",
|
||||
"536e0d6",
|
||||
"c7cce81",
|
||||
"3ca8c00b",
|
||||
"bd58caea",
|
||||
"21766bd8",
|
||||
"81ba3840",
|
||||
"b0d3e8c2",
|
||||
"c07361fb",
|
||||
"4bdd2d27",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"fec4df65",
|
||||
"6f77380d",
|
||||
"7667d165",
|
||||
"994bde5f",
|
||||
"eba8c8d4",
|
||||
"83a481ab",
|
||||
"5f5b9c3b",
|
||||
"8e488082",
|
||||
"5ff24a8c",
|
||||
"b0b1dc39",
|
||||
"caffbdeb",
|
||||
"eed9edb0",
|
||||
"eb73da71",
|
||||
"34958994",
|
||||
"37affc8b",
|
||||
"f7f70cf6",
|
||||
"30e6ffd0",
|
||||
"367b34fa",
|
||||
"7c6b16f3",
|
||||
"f938a928",
|
||||
"f6955ec9",
|
||||
"6101654d",
|
||||
"ef23b353",
|
||||
"f797fc81",
|
||||
"81863d95",
|
||||
"84fb1fa5",
|
||||
"8a929153",
|
||||
"dab785bd",
|
||||
"14e1b416",
|
||||
"e305d78b",
|
||||
"5009bcd1",
|
||||
"adab866f",
|
||||
"864ae815",
|
||||
"eb3d573f",
|
||||
"d2596197",
|
||||
"9b385418",
|
||||
"72ba8004",
|
||||
"fc3a013f",
|
||||
"f4894cf5",
|
||||
"d5088e98",
|
||||
"34c98a3e",
|
||||
"639f36ea",
|
||||
"c44ab614",
|
||||
"d312b3cc",
|
||||
"b0bdbb49",
|
||||
"6101654d",
|
||||
"c98bf28e",
|
||||
"eeb2e39c",
|
||||
"2641d5f2",
|
||||
"8ced6877",
|
||||
"9846f084",
|
||||
"bf54ca54",
|
||||
"66605fca",
|
||||
"f8cbd6c1",
|
||||
"7b73717e",
|
||||
"d01005b1",
|
||||
"fa4b56ed",
|
||||
"9107c666",
|
||||
"ab34bd00",
|
||||
"15f734e5",
|
||||
"2c41730b",
|
||||
"5a5200b8",
|
||||
"9ba6b927",
|
||||
"ed5e19e5",
|
||||
"a0bdbd18",
|
||||
"6c9cb9ae",
|
||||
"586555a4",
|
||||
"ecfe8200",
|
||||
"320ace1f",
|
||||
"3225fdd6",
|
||||
"c7b1e1fd",
|
||||
"cccfe5ef",
|
||||
"41770a6c",
|
||||
"976195ff",
|
||||
"2bfb3f75",
|
||||
"225cc3c7",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"3ced547a"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"a287c41b",
|
||||
"ab4d2319",
|
||||
"11850d2f",
|
||||
"6a5201ad",
|
||||
"41949105",
|
||||
"4ac88698",
|
||||
"c5afbfaa",
|
||||
"1424daf2",
|
||||
"14166273",
|
||||
"799a7e6f",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"ecc890b3",
|
||||
"4dbdfd86",
|
||||
"62c8318",
|
||||
"76c51821",
|
||||
"5b2c7887",
|
||||
"536e0d6",
|
||||
"6109061f",
|
||||
"cbdfd012",
|
||||
"a77974e1",
|
||||
"d2181ece",
|
||||
"1f1a9a1b",
|
||||
"e9ac5ae5",
|
||||
"725d11ea",
|
||||
"4fc75eed",
|
||||
"19b5ce10",
|
||||
"f74f0d0d",
|
||||
"70d465c7",
|
||||
"e8ee6814",
|
||||
"f86cae5e",
|
||||
"a4d30dab",
|
||||
"f041ab3c",
|
||||
"9fab7823",
|
||||
"2c06980d",
|
||||
"83dfe3f",
|
||||
"55f53409",
|
||||
"6bb51a4",
|
||||
"5db348d1",
|
||||
"17476c2e",
|
||||
"a89b7c71",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"27e4dbb6",
|
||||
"536e0d6",
|
||||
"c7cce81",
|
||||
"3ca8c00b",
|
||||
"bd58caea",
|
||||
"21766bd8",
|
||||
"81ba3840",
|
||||
"b0d3e8c2",
|
||||
"c07361fb",
|
||||
"4bdd2d27",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"fec4df65",
|
||||
"6f77380d",
|
||||
"7667d165",
|
||||
"994bde5f",
|
||||
"eba8c8d4",
|
||||
"83a481ab",
|
||||
"5f5b9c3b",
|
||||
"8e488082",
|
||||
"5ff24a8c",
|
||||
"b0b1dc39",
|
||||
"caffbdeb",
|
||||
"eed9edb0",
|
||||
"eb73da71",
|
||||
"34958994",
|
||||
"37affc8b",
|
||||
"f7f70cf6",
|
||||
"30e6ffd0",
|
||||
"367b34fa",
|
||||
"7c6b16f3",
|
||||
"f938a928",
|
||||
"f6955ec9",
|
||||
"6101654d",
|
||||
"ef23b353",
|
||||
"f797fc81",
|
||||
"81863d95",
|
||||
"84fb1fa5",
|
||||
"8a929153",
|
||||
"dab785bd",
|
||||
"14e1b416",
|
||||
"e305d78b",
|
||||
"5009bcd1",
|
||||
"adab866f",
|
||||
"864ae815",
|
||||
"eb3d573f",
|
||||
"d2596197",
|
||||
"9b385418",
|
||||
"72ba8004",
|
||||
"fc3a013f",
|
||||
"f4894cf5",
|
||||
"d5088e98",
|
||||
"34c98a3e",
|
||||
"639f36ea",
|
||||
"c44ab614",
|
||||
"d312b3cc",
|
||||
"b0bdbb49",
|
||||
"6101654d",
|
||||
"c98bf28e",
|
||||
"eeb2e39c",
|
||||
"2641d5f2",
|
||||
"8ced6877",
|
||||
"9846f084",
|
||||
"bf54ca54",
|
||||
"66605fca",
|
||||
"f8cbd6c1",
|
||||
"7b73717e",
|
||||
"d01005b1",
|
||||
"fa4b56ed",
|
||||
"9107c666",
|
||||
"ab34bd00",
|
||||
"15f734e5",
|
||||
"2c41730b",
|
||||
"5a5200b8",
|
||||
"9ba6b927",
|
||||
"ed5e19e5",
|
||||
"a0bdbd18",
|
||||
"6c9cb9ae",
|
||||
"586555a4",
|
||||
"ecfe8200",
|
||||
"320ace1f",
|
||||
"3225fdd6",
|
||||
"c7b1e1fd",
|
||||
"cccfe5ef",
|
||||
"41770a6c",
|
||||
"976195ff",
|
||||
"2bfb3f75",
|
||||
"225cc3c7",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"3ced547a"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
## 需求探索
|
||||
|
||||
### 需要哪些核心功能?
|
||||
|
||||
* 向用户发送消息。
|
||||
* 接收来自用户的消息。
|
||||
* 查看用户与用户的聊天记录。
|
||||
|
||||
### 消息接收是实时的吗?
|
||||
|
||||
是的,用户应该尽可能快地实时接收消息,而无需刷新页面。
|
||||
|
||||
### 应该支持什么样的消息格式?
|
||||
|
||||
让我们支持可以包含表情符号的文本格式。 如果有时间,我们可以讨论支持图像。
|
||||
|
||||
### 应用程序需要离线工作吗?
|
||||
|
||||
是的,如果可能的话。 传出的消息应该被存储并在应用程序上线时发送出去,即使它们处于离线状态,也应该允许用户浏览消息。
|
||||
|
||||
### 是否有群聊?
|
||||
|
||||
我们可以假设它是一对一的消息服务。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
传统应用程序与可以离线使用的聊天应用程序之间的主要区别在于,如果应用程序失去网络连接,理想情况下,某些功能(例如浏览设备上的消息和搜索)仍应起作用。 这极大地影响了应用程序架构,并且它将与传统的 Web 应用程序大相径庭。
|
||||
|
||||
### 要处理的棘手场景
|
||||
|
||||
首先,让我们意识到我们需要在聊天应用程序中处理的各种棘手场景及其影响。
|
||||
|
||||
* **在同一浏览器的不同选项卡中使用该应用程序。** 用户可能会这样做,因为他们想同时与不同的人聊天,而不是在同一选项卡中切换对话,而是在选项卡之间切换。
|
||||
* 用户应该看到每个对话的相同消息 -> 依赖于可在同一浏览器中的不同选项卡之间访问的存储。
|
||||
* **在不同的设备/浏览器上使用该应用程序。** 相同设备,不同浏览器的情况很少见,但用户同时使用多个设备(工作和个人设备)的情况并不少见。
|
||||
* 用户应该看到每个对话的相同消息 -> 在初始加载时与服务器同步并获取最新数据。
|
||||
* **应用程序在使用过程中离线。** 用户在移动过程中可能会失去连接,并且会穿过低连接区域(地铁中常见的情况)。
|
||||
* 传出的消息尚未全部完成 -> 应用程序再次上线时应重试,或者如果它们已写入服务器但未收到更新,则应更新其状态。
|
||||
* 应用程序离线时正在发送消息 -> 这些消息应在应用程序下次上线时发送出去。 但是,这应该仅针对最近发送的消息完成。 如果这些消息发送的时间太长,则对话可能已经超出了主题(可能使用其他设备),并且重试发送它不再有意义。
|
||||
* **上述场景的组合。** 生活变得更加艰难!
|
||||
|
||||
我们选择的架构应该处理所有这些场景。
|
||||
|
||||
### 客户端数据库
|
||||
|
||||
在客户端存储数据的一种方法是使用客户端数据库(以下简称数据库)。UI 从数据库中读取数据,就像它是一个仅客户端的应用程序,而不是传统的应用程序,在传统应用程序中,UI 直接发出 HTTP 查询并显示获取的数据。UI 不知道也不应该知道数据库从哪里获取数据。数据库从哪里获取数据应该是数据层的实现细节。
|
||||
|
||||
同一浏览器中的不同标签页访问相同的客户端数据库。这确保了标签页之间的数据一致性,并有助于解决“在同一浏览器中的不同标签页上使用应用程序”场景中的 UI 一致性问题。但是,当收到“新消息”事件的通知时,我们必须注意不要向数据库中插入两次。
|
||||
|
||||
### 数据同步器
|
||||
|
||||
数据同步器是一个负责将客户端数据库与服务器同步的模块。
|
||||
|
||||
#### 发送消息
|
||||
|
||||
当用户发出消息(或用户通常进行的任何更新)时,我们希望立即反映这些更改。在显示更新的 UI 之前等待服务器的确认会带来糟糕的用户体验。
|
||||
|
||||
因此,传出的聊天消息/用户操作首先被插入到数据库中,并且它们被标记为待处理。待处理的消息也会立即反映在 UI 中。请注意,聊天应用程序中的消息具有指示各种消息传递状态的指示器。
|
||||
|
||||
| 消息状态 | 描述 | Messenger | WhatsApp |
|
||||
| --- | --- | --- | --- |
|
||||
| 正在发送 | 应用程序正在尝试发送消息 | 空心圆 | 时钟图标 |
|
||||
| 已发送 | 消息已成功发送到服务器 | 轮廓圆中的复选标记 | 单个灰色复选标记 |
|
||||
| 已送达 | 消息已送达给收件人 | 填充圆中的复选标记 | 双灰色复选标记 |
|
||||
| 已读 | 收件人已阅读消息 | 用户资料图片的微型版本 | 双蓝色复选标记(或勾号) |
|
||||
| 失败 | 消息发送失败 | 圆圈中的感叹号图标 | 圆圈中的感叹号图标 |
|
||||
|
||||
*来源:[Messenger 帮助中心](https://www.facebook.com/help/messenger-app/926389207386625) 和 [WhatsApp 帮助中心](https://faq.whatsapp.com/665923838265756/)*
|
||||
|
||||
{/* TODO: 替换为图片 */}
|
||||
|
||||
您可能听说过“她给我打了双蓝勾”这句话,意思是某人阅读了消息但没有回复。现在您知道了其他消息状态是什么 😎。
|
||||
|
||||
在数据库同步期间,服务器收到并确认该操作后,它会向应用程序发回响应,并且这些消息可以被标记为“已发送”。
|
||||
|
||||
由于可以在不同的对话中并行发送多条消息(在实际应用中,甚至有更多操作,如反应、删除消息),因此需要一个调度程序来确保操作以正确的顺序发送到服务器,跟踪请求状态,重试请求失败等。
|
||||
|
||||
#### 接收实时更新
|
||||
|
||||
因为我们希望实时接收消息更新,所以应用程序需要尽快收到来自后端的关于新消息的通知。我们将在“优化”部分讨论几种获取实时更新的方法。
|
||||
|
||||
### 服务器端渲染还是客户端渲染?
|
||||
|
||||
聊天应用程序具有以下特征:
|
||||
|
||||
* 由于发送和接收消息的频率很高,因此本质上具有高度交互性。该页面可能需要大量的 JavaScript。
|
||||
* 只能在登录后才能访问消息。
|
||||
* 消息不必(也不应该!)被搜索引擎索引。
|
||||
* 期望快速的初始加载速度,但不是最关键的。
|
||||
|
||||
考虑到以上几点,纯客户端渲染和单页应用程序的整体架构将运行良好。我们可以使用服务器端渲染 (SSR) 和客户端水合,就像在 [新闻提要系统设计](/questions/system-design/news-feed-facebook) 和 [照片共享应用程序系统设计](/questions/system-design/photo-sharing-instagram) 中一样,以实现快速的初始加载,但 SSR 的好处将仅限于性能提升,因为聊天应用程序不需要对 SEO 友好。启用 SSR 带来的额外工程复杂性可能不值得。
|
||||
|
||||
### 架构图
|
||||
|
||||

|
||||
|
||||
#### 组件职责
|
||||
|
||||
* **聊天 UI**:包含一个对话列表和当前选定的对话/会话
|
||||
* **对话列表**:显示对话列表(用户、最后一条消息、最后一条消息时间戳)。
|
||||
* **选定的对话**:对话中的消息列表和一个用于输入新消息的输入框。
|
||||
* **控制器**:控制应用程序内的数据流。从数据库中获取数据以在 UI 中显示。将数据写入数据库。
|
||||
* **数据同步器**:包含数据库并管理传出消息的模块。 还会从服务器接收更新并相应地更新数据库。
|
||||
* **客户端数据库**:用于存储 UI 中需要显示的所有数据的数据库。
|
||||
* **消息调度程序**:监视传出消息,安排它们发送并管理它们的状态。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
为了简单起见,我们将只关注应用程序的聊天功能。
|
||||
|
||||
### 客户端数据库
|
||||
|
||||
应用程序所需的大部分数据将存储在客户端数据库中。 任何需要离线功能的数据都应该进入数据库。 这是数据库表的实体关系图。
|
||||
|
||||
<img alt="聊天应用程序数据模型" className="mx-auto w-full max-w-5xl" src="/img/questions/chat-application-messenger/chat-application-data-model.png" />
|
||||
|
||||
| 表/实体 | 同步到服务器 | 由...使用 | 描述 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Conversation` | 是 | 对话列表 | 用户之间的对话(目前只有两个用户) |
|
||||
| `Message` | 是 | 对话 | 用户发送的文本消息。 `status` 是 `sending`、`sent`、`delivered`、`read`、`failed` 之一 |
|
||||
| `User` | 是 | 全部 | 用户身份 |
|
||||
| `ConversationUser` | 是 | - | 将用户和对话关联起来以允许多对多关系。 `Conversation` 目前最多只有两个 `User`,但通过这种设计,它可以支持所需的数量 |
|
||||
| `DraftMessage` | 否 | 对话 | 存储半写、未发送的消息 |
|
||||
| `SendMessageRequest` | 否 | 消息调度程序 | 跟踪要发送的消息的状态 |
|
||||
|
||||
请注意,`DraftMessage` 和 `SendMessageRequest` 表不会同步到服务器,并且仅限客户端。 但是,它们仍应位于数据库中,而不是仅限客户端状态,因为它们应该在会话之间保持持久性。
|
||||
|
||||
* `DraftMessage`:此表存储用户在对话的消息输入框中键入但尚未发送的消息。 这必须持久保存在数据库中(而不是仅限客户端状态),这样如果用户退出应用程序并再次打开它,他们就不会丢失未发送的消息。 每个对话每个用户最多可以有一个 `DraftMessage`。
|
||||
* 请注意,草稿消息不会与服务器同步,因此它保留在当前设备中。 将草稿消息与服务器同步以便它们可以在所有设备上访问是完全可行的,但这是一个产品决策,为了专注于核心用例,我们现在不会对此进行探讨。
|
||||
* `SendMessageRequest`:此表存储与用户已发送但尚未被服务器确认的每条消息相关的数据。 `status` 是一个枚举,可以是以下之一:
|
||||
* `pending`:要发送的新消息的默认状态。
|
||||
* `in_flight`:应用程序已将消息发送到服务器,但尚未收到响应。
|
||||
* `fail`:当服务器返回错误或发送请求超时时。 我们跟踪它失败的次数 `fail_count`,以便我们知道是继续重试(使用指数退避)还是在一定次数的失败后停止重试。
|
||||
* `success`:表示消息已收到并被服务器确认。 严格来说,不需要此枚举值,因为当客户端收到服务器确认时,可以从表中删除此行。
|
||||
|
||||
### 仅客户端状态
|
||||
|
||||
这些是无需在数据库中持久保存的状态字段,即如果用户通过关闭浏览器选项卡/窗口退出应用程序,则丢失此数据是可以的。
|
||||
|
||||
* **选定的对话**:当前选定的对话。
|
||||
* **对话滚动位置**:每个对话中的滚动位置。 每当用户在对话之间切换时,恢复滚动位置很有用。
|
||||
* **对话传出消息**:这是用户在特定对话中键入的任何内容。 它几乎与 `DraftMessage` 相同,只是我们不应该在每次按键时保存到数据库中。 我们仅在用户停止键入(通过模糊/去抖动)或节流以在每 X 毫秒后将值保存到数据库后才持久保存到 `DraftMessage` 表中。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
需要以下 API:
|
||||
|
||||
* 发送消息
|
||||
* 同步传出消息
|
||||
* 服务器事件
|
||||
* 获取对话
|
||||
* 获取对话消息
|
||||
|
||||
### 发送消息
|
||||
|
||||
1. 将一行添加到 `Message` 表中,状态为 `sending`。
|
||||
2. 将一行添加到 `SendMessageRequest` 表中,状态为 `pending`。
|
||||
3. 对话 UI 从 `Message` 表中读取并显示带有“正在发送”指示器的新消息。
|
||||
4. 删除当前对话/会话的任何 `DraftMessage` 行。
|
||||
5. 此时,没有剩余的同步步骤需要完成。 消息调度程序将负责将 `pending` 消息与服务器同步。
|
||||
|
||||
### 同步传出消息
|
||||
|
||||
消息调度程序将负责将传出消息与服务器同步。 它将维护自己的任务队列并监视 `SendMessageRequest` 表。 由于任务队列需要在选项卡之间同步,因此它不应存储在浏览器内存中,也可以使用另一个表。
|
||||
|
||||
只要表不为空,它就会获取前 X 行(按 id 排序),并尝试通过将任务添加到其自己的任务队列来处理它们。X 是一个可配置的值。取决于行的 `status` 列:
|
||||
|
||||
* `pending`:将任务排队,通过实时通道将消息发送到服务器。将行的 `status` 更新为 `in_flight`。
|
||||
* `in_flight`:检查 `last_sent_at` 时间戳。如果它超过了超时阈值,则将行的 `status` 更新为 `fail`,并将 `fail_count` 增加 1。
|
||||
* `fail`:将任务排队,以便在将来的某个时间重试发送此消息。延迟时间取决于 `fail_count`。使用指数退避重试策略,延迟时间将随 `fail_count` 呈指数增长。
|
||||
|
||||
### 服务器事件
|
||||
|
||||
数据同步器将以事件的形式从服务器接收实时更新。每个事件可以有一个类型和一个 payload 字段。payload 的形状取决于实际事件。
|
||||
|
||||
```json
|
||||
// 服务器推送的示例事件 payload。
|
||||
{
|
||||
"event_name": "incoming_message",
|
||||
"payload": {
|
||||
"foo": "value_a",
|
||||
"bar": "value_b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这些是必要的各种事件:
|
||||
|
||||
#### `message_sent` 事件
|
||||
|
||||
1. 将 `Message` 的 `status` 更新为 `sent`。
|
||||
2. 在消息调度程序中清理此消息:
|
||||
1. 从 `SendMessageRequest` 表中删除与此消息对应的行。此消息已由服务器接收,不再处于 `pending` 或 `in_flight` 状态。
|
||||
2. 删除任务队列中与此消息相关的任何任务。
|
||||
3. 更新 UI
|
||||
* 如果当前显示该消息的对话,则通知对话 UI 进行更新。
|
||||
|
||||
#### `message_delivered` 事件
|
||||
|
||||
1. 将 `Message` 的 `status` 更新为 `delivered`。
|
||||
2. 更新 UI
|
||||
* 如果当前显示该消息的对话,则通知对话 UI 进行更新。
|
||||
|
||||
#### `message_failed` 事件
|
||||
|
||||
1. 更新 `SendMessageRequest` 表中与此消息对应的行,并将 `status` 更改为 `fail`,并将 `fail_count` 增加 1。
|
||||
1. 请注意,我们尚未将 `Message` 表中行的 `status` 修改为 `fail`。在重试发送消息之前,该消息尚未被视为失败。
|
||||
2. 更新 UI
|
||||
* 如果当前显示该消息的对话,则通知对话 UI 进行更新。
|
||||
|
||||
#### `incoming_message` 事件
|
||||
|
||||
1. 将新消息追加到 `Message` 表中。
|
||||
1. 如果不存在,则在 `Conversation` 表中创建一个新行。
|
||||
2. 如果消息的发件人尚未存在,则在 `User` 表中为该发件人创建一个新行。
|
||||
2. 更新 UI
|
||||
* 通知对话列表 UI 进行更新。更新 UI 以将此对话显示在顶部。如果对话列表按每个对话的最新消息的递减时间戳排序,它将自动显示在顶部。
|
||||
* 如果当前显示该消息的对话,则通知对话 UI 进行更新。
|
||||
|
||||
#### `sync` 事件
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
**进行中。** 当客户端首次连接到服务器时,会触发此事件。当客户端首次连接到服务器时,它们可能在它们包含的数据方面滞后。这里的目标是通过服务器发送客户端缺少的所有数据,使每个客户端与最新的服务器状态保持同步。
|
||||
|
||||
指示客户端状态给服务器的可能方法:
|
||||
|
||||
1. **客户端的上次更新时间戳**:服务器将收集时间戳之后创建的所有新实体(消息、对话),并发送给客户端,供客户端插入到数据库中。
|
||||
2. **每个对话的游标**:数据库游标是一种机制,用于遍历数据库中的记录。类似于基于游标的分页 API,游标可用于指示客户端收到的对话中的最后一条消息,以及该消息之后的消息。
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### 客户端数据库
|
||||
|
||||
#### 决定客户端存储
|
||||
|
||||
有几种在客户端存储数据的方法:Cookies、Web Storage 和 IndexedDB。请参考测验问题,了解[关于 cookies 和 Web Storage 机制的比较](/questions/quiz/describe-the-difference-between-a-cookie-sessionstorage-and-localstorage)。
|
||||
|
||||
由于容量极小(每个域 4kb),Cookies 无法使用。`localStorage`(Web Storages 之一)不太适合,因为它不支持结构化数据,而结构化数据对于像聊天这样重要的应用程序至关重要。
|
||||
|
||||
[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) 是我们在这里的最佳选择,它是一个用于客户端存储大量**结构化数据**(包括文件/blob)的低级 API。其他有用的功能包括数据库索引、表、游标、事务,主要通过异步 API 实现。
|
||||
|
||||
#### 跨标签页同步
|
||||
|
||||
由于 IndexedDB 是一种客户端存储机制,因此数据可以在各个标签页之间访问,并且它解决了顶部概述的“用户应该在同一浏览器中的不同标签页上的应用程序中看到相同的消息”场景。
|
||||
|
||||
但是,浏览器标签页并不知道其他标签页中的 `IndexedDB` 数据更改。要通知其他标签页有关数据库更改的信息,请使用 [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel),它允许同一来源的不同窗口/标签页/框架之间进行通信。
|
||||
|
||||
#### 将客户端数据库与服务器同步
|
||||
|
||||
客户端和服务器之间的消息双向同步很复杂。
|
||||
|
||||
* **乱序消息**:不能保证消息按发送顺序接收。是否应该根据时间戳将乱序消息插入到现有消息之间,或者是否应该始终将它们附加到对话的底部?
|
||||
* **获取新消息**:客户端需要告诉服务器上次更新的时间(可以是收到的最后一条消息,也可以是上次从服务器提取的时间戳),服务器会找出尚未发送给客户端的消息并发送它们。
|
||||
* **发送待处理消息**:应用程序离线时发送的消息应存储在待处理的传出消息队列中,并在应用程序上线时发送。
|
||||
|
||||
#### 其他问题
|
||||
|
||||
* 不支持的环境,例如 Firefox 和 Safari 上的隐私/隐身模式。
|
||||
* 存储限制。
|
||||
* 初始化/打开数据库时出错。
|
||||
* IndexedDB 附带许多[问题、错误和怪癖](https://gist.github.com/pesterhazy/4de96193af89a6dd5ce682ce2adff49a)。
|
||||
* [使用 IndexedDB 的最佳实践](https://web.dev/indexeddb-best-practices/)
|
||||
|
||||
{/* TODO: 详细讨论其中一些问题。 */}
|
||||
|
||||
### 实时更新
|
||||
|
||||
实时消息传递意味着消息的接收者会立即(或接近立即)收到新消息,而无需他们重新启动应用程序/刷新页面或手动触发按钮来获取新消息。
|
||||
|
||||
实现实时消息传递的几种方法:
|
||||
|
||||
* 短轮询(或定期轮询)
|
||||
* 长轮询
|
||||
* Web Sockets
|
||||
|
||||
**进行中**:评估每种实时机制的优缺点。Web Sockets 是现代选择,也是大多数聊天应用程序使用的机制。
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
参考:[WebSockets vs 长轮询](https://ably.com/blog/websockets-vs-long-polling)
|
||||
|
||||
### 网络
|
||||
|
||||
连接失败非常常见,因为用户可能在交通工具上使用聊天应用程序,并且进出连接不良的区域。消息可能无法发送出去,以及其他问题:
|
||||
|
||||
* **离线使用**:应用程序应检测设备是否离线,如果离线,则不尝试发送消息。消息应添加到 `SendMessageRequest` 表中。
|
||||
* **失败**:应使用指数退避重试失败的传出消息。
|
||||
* 如果在 X 次重试后消息未成功发送,则显示错误消息。
|
||||
* **批处理**:如果消息发送速度很快(在几秒钟内),则可以将传出消息分批发送并作为单个消息发送。如果用户在发送最后一条消息后仍在键入,则应用程序可以检测到,并且可能等待下一条消息完成,然后再发出请求(类似于防抖)。此批处理逻辑最好在消息调度程序中实现。
|
||||
* **乱序**:如果我们通过单独的请求发送每条消息,则无法保证消息以客户端发送的顺序到达服务器。但是,顺序发送消息也不是很理想。批处理通过在一个有效负载中发送多条消息,但也保持顺序来帮助缓解此问题。
|
||||
* **断开连接**:应用程序应在断开连接后自动尝试重新连接,而无需用户刷新页面。
|
||||
|
||||
### 性能
|
||||
|
||||
* 延迟加载初始加载不需要的组件(例如表情符号选择器、任何弹出窗口/模态框)。
|
||||
* 对话中的长消息列表使用窗口化/虚拟化。
|
||||
|
||||
### 可访问性
|
||||
|
||||
实现基本的键盘快捷键:
|
||||
|
||||
* 消息撰写器
|
||||
* 按 Enter 键发送消息。
|
||||
* Shift + Enter 在消息中添加新行。
|
||||
* 在对话之间
|
||||
* 快捷方式聚焦搜索栏。
|
||||
* <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Up</kbd>/<kbd>Down</kbd> 在
|
||||
对话之间切换。
|
||||
* 在某些桌面客户端上,<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + 数字键将带您进入对话列表中的第 n 个对话。
|
||||
|
||||
### 离线支持
|
||||
|
||||
该应用程序可以构建为渐进式 Web 应用程序 (PWA),它使用服务工作者来缓存资产 (HTML/JS/CSS),以便该应用程序同时具有离线使用的代码和数据。
|
||||
|
||||
使用 PWA 还允许浏览器通知,这对于通知用户即使选项卡未处于焦点/可见状态也有新消息非常有用。
|
||||
|
||||
### 用户体验
|
||||
|
||||
#### 保持滚动位置
|
||||
|
||||
由于可能会在列表的上方和下方添加新消息,因此滚动位置是消息传递应用程序中一个棘手的问题。
|
||||
|
||||
滚动位置应为:
|
||||
|
||||
1. 当新消息进入并且滚动位置已位于列表底部时,保持在消息列表的底部。这是大多数情况下的默认方案。
|
||||
2. 当滚动位置不在底部时保持,以便当前可见的内容仍然可见。向上滚动以阅读较旧的聊天消息时,应保持滚动位置,并且当前可见的元素不应移动,即使更多 DOM 元素将添加到顶部。应用程序可以计算当前的滚动偏移量、要附加的新元素的高度,并修改滚动高度以添加新元素的高度。
|
||||
|
||||
以下是可能更改(滚动/客户端)高度的事件:
|
||||
|
||||
* 在下方插入新消息(接收新消息时)。
|
||||
* 在上方插入新消息(搜索历史记录时)。
|
||||
* 窗口调整大小。
|
||||
* 媒体完全加载,其高度与加载占位符不同。
|
||||
* 通过使用固定高度的占位符并在该元素内呈现媒体来避免此问题。
|
||||
* 页面缩放更改。
|
||||
|
||||
应根据上面列出的情况保持滚动位置(位于底部或显示相同的内容)。
|
||||
|
||||
#### 其他可能的改进
|
||||
|
||||
* 添加一个“滚动到底部”按钮,当用户在对话消息中向上滚动时可见。
|
||||
|
||||
### 渐变效果
|
||||
|
||||
这篇文章由 CSS Trick 撰写,向您展示了实现 [Messenger 的聊天消息渐变背景](https://css-tricks.com/recreating-the-facebook-messenger-gradient-effect-with-css/) 的各种方法。
|
||||
|
||||
### 过时客户端
|
||||
|
||||
对于非常过时的客户端,他们将不得不下载自上次同步以来所有缺少的邮件列表,这可能非常庞大。这会导致启动应用程序和能够使用它之间出现明显的延迟。很少有人会喜欢等待数以万计的消息被提取并插入到客户端数据库中,然后才能使用该应用程序的过程。
|
||||
|
||||
一种可行的方法是将其视为数据库中不存在的全新加载/现有数据,并与服务器进行完全同步,仅获取最新 M 个对话的最新 N 条消息。
|
||||
|
||||
### 高级
|
||||
|
||||
这些功能不会在此解决方案中讨论,但如果时间允许,您可能希望与面试官讨论它们。
|
||||
|
||||
* 搜索(使用在线和离线搜索的混合)
|
||||
* i18n
|
||||
* 端到端加密
|
||||
* [Facebook Messenger 中的 E2E 加密的挑战](https://www.youtube.com/watch?v=-IXJ7Q01gpY)
|
||||
* 传递/已读回执
|
||||
* 离线/乐观读取
|
||||
* 反应
|
||||
* 正在输入指示器
|
||||
* 消失的消息
|
||||
* 通知
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
* Facebook & Messenger
|
||||
* [在桌面上启动 Instagram 消息传递](https://engineering.fb.com/2022/07/26/web/launching-instagram-messaging-on-desktop/)
|
||||
* [构建 Facebook Messenger](https://www.facebook.com/notes/10158791547142200/)
|
||||
* [逆向工程 Facebook Messenger API](https://intuitiveexplanations.com/tech/messenger)
|
||||
* [与 Mohsen Agsen 一起进行 Facebook Messenger 工程](https://softwareengineeringdaily.com/2020/03/31/facebook-messenger-engineering-with-mohsen-agsen/)
|
||||
* [F8 2019:Facebook:更轻、更快、更简单的 Messenger](https://www.youtube.com/watch?v=ulVLD2yzCrc)
|
||||
* [在 Facebook 上构建实时基础设施 - Facebook - SRECon2017](https://www.youtube.com/watch?v=ODkEWsO5I30)
|
||||
* [Facebook Messenger RTC – 规模的挑战和机遇](https://www.youtube.com/watch?v=F7UWvflUZoc)
|
||||
* [为 Messenger 构建移动优先的基础设施](https://engineering.fb.com/2014/10/09/production-engineering/building-mobile-first-infrastructure-for-messenger/)
|
||||
* [用于消息传递的 MySQL - @Scale 2014 - 数据](https://www.youtube.com/watch?v=eADBCKKf8PA)
|
||||
* [LightSpeed 项目:重写 Messenger 代码库,以实现更快、更小、更简单的消息传递应用程序](https://engineering.fb.com/2020/03/02/data-infrastructure/messenger/)
|
||||
* Slack
|
||||
* [在 Slack 中管理焦点转换](https://slack.engineering/managing-focus-transitions-in-slack/)
|
||||
* [Gantry:Slack 的快速启动前端框架](https://slack.engineering/gantry-slacks-fast-booting-frontend-framework/)
|
||||
* [通过懒惰让 Slack 变得更快](https://slack.engineering/making-slack-faster-by-being-lazy/)
|
||||
* [通过懒惰让 Slack 变得更快:第 2 部分](https://slack.engineering/making-slack-faster-by-being-lazy-part-2/)
|
||||
* [通过增量启动更快地进入 Slack](https://slack.engineering/getting-to-slack-faster-with-incremental-boot/)
|
||||
* [Slack 上的 Service Workers:我们对更快启动时间和离线支持的追求](https://slack.engineering/service-workers-at-slack-our-quest-for-faster-boot-times-and-offline-support/)
|
||||
* [本地化 Slack](https://slack.engineering/localizing-slack/)
|
||||
* Airbnb
|
||||
* [消息同步 — 在 Airbnb 上扩展移动消息传递](https://medium.com/airbnb-engineering/messaging-sync-scaling-mobile-messaging-at-airbnb-659142036f06)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "40b23b81",
|
||||
"excerpt": "17a8349c"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"3dbfa9e5",
|
||||
"7506e912",
|
||||
"9ec6d64c",
|
||||
"19edc35d"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"3dbfa9e5",
|
||||
"7506e912",
|
||||
"9ec6d64c",
|
||||
"19edc35d"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
title: Google Docs
|
||||
excerpt: 设计一个类似 Google Docs 和 Notion 的协作编辑器
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个协作文档编辑器,允许用户实时协作处理文档。
|
||||
|
||||
**注意**:本文重点介绍协作文本编辑软件的协作方面,而对文本编辑本身着墨不多。要深入了解富文本编辑,请看[富文本编辑器系统设计文章](/questions/system-design/rich-text-editor)。
|
||||
|
||||
### 真实案例
|
||||
|
||||
* [Google Docs](https://www.google.com/docs/about/)
|
||||
* [Notion](https://www.notion.so/)
|
||||
* [Quip](https://quip.com/)
|
||||
* [Etherpad](https://etherpad.org/)
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"1bc79f42",
|
||||
"2a7816d0",
|
||||
"402fa11e",
|
||||
"7513ca34",
|
||||
"237c09e4",
|
||||
"9996f8bb",
|
||||
"a7d3c90c",
|
||||
"51021cd8",
|
||||
"2fdce2b9",
|
||||
"990fb35",
|
||||
"9226e17c",
|
||||
"2ac22e3f",
|
||||
"2a7816d0",
|
||||
"67c64d35",
|
||||
"2c098c19",
|
||||
"430d3bb6",
|
||||
"e145371d",
|
||||
"b01d0407",
|
||||
"fc64d7e1",
|
||||
"6d5f777",
|
||||
"8e3e0656",
|
||||
"2f7161dc",
|
||||
"b5a6400b",
|
||||
"cdaedfdd",
|
||||
"63b4c40c",
|
||||
"5eb994ea",
|
||||
"1429bbb",
|
||||
"cd701f4e",
|
||||
"b89cff83",
|
||||
"c8421ade",
|
||||
"14c62cee",
|
||||
"458ece39",
|
||||
"ade783d0",
|
||||
"f6203d63",
|
||||
"4fa35f68",
|
||||
"48ee6f84",
|
||||
"ea4bcb7",
|
||||
"621fb9ee",
|
||||
"1fcb00fe",
|
||||
"b95b5e39",
|
||||
"d7457dcd",
|
||||
"9b4c98ba",
|
||||
"8f96e1dd",
|
||||
"304a8a2d",
|
||||
"cfae6cf",
|
||||
"2081cd57",
|
||||
"465b855b",
|
||||
"b655ddf9",
|
||||
"6f8a2d11",
|
||||
"2db632c4",
|
||||
"36c8c0ea",
|
||||
"6e22a3bc",
|
||||
"a84eb575",
|
||||
"465b855b",
|
||||
"937cdf14",
|
||||
"6f8a2d11",
|
||||
"e99fee19",
|
||||
"74fe0d93",
|
||||
"ca96c0c7",
|
||||
"41d3eb2f",
|
||||
"27a140c5",
|
||||
"1b41dc29",
|
||||
"1261e1ae",
|
||||
"cd959f60",
|
||||
"77b116c4",
|
||||
"9c5c338",
|
||||
"f83d35b7",
|
||||
"71dc439",
|
||||
"74020a07",
|
||||
"b918ad1e",
|
||||
"5becd493",
|
||||
"d76feec3",
|
||||
"c7c423b0",
|
||||
"913bc548",
|
||||
"83051a0e",
|
||||
"88ec73b",
|
||||
"9402030a",
|
||||
"92b17661",
|
||||
"4667d540",
|
||||
"a94d9764",
|
||||
"5516a422",
|
||||
"3e0e62e7",
|
||||
"4d2e4d01",
|
||||
"bcbfe870",
|
||||
"e1d96a4f",
|
||||
"fbfbbc4d",
|
||||
"109aee33",
|
||||
"f0de185d",
|
||||
"b7d37895",
|
||||
"d0d65788",
|
||||
"8e360be6",
|
||||
"bbea449e",
|
||||
"4451f6c4",
|
||||
"39894194",
|
||||
"b5f935bb",
|
||||
"45d9105",
|
||||
"c2b36c7d",
|
||||
"d9e99c07",
|
||||
"a5a064c3",
|
||||
"3d67c50e",
|
||||
"4c44580c",
|
||||
"56b03a65",
|
||||
"9749898",
|
||||
"4a3351dc",
|
||||
"1fc9ccb4",
|
||||
"39b23284",
|
||||
"b32f33a5",
|
||||
"f2372c57",
|
||||
"1069afeb",
|
||||
"db9ffde1",
|
||||
"8ba31396",
|
||||
"2b9002d4",
|
||||
"21aab5be",
|
||||
"d8781aa",
|
||||
"e8dabfbf",
|
||||
"fa5e5a0a",
|
||||
"651914d9",
|
||||
"b5d55de0",
|
||||
"80d8a846",
|
||||
"9dd1639d",
|
||||
"335ef06d",
|
||||
"331bb270",
|
||||
"6132904b",
|
||||
"de9424b",
|
||||
"d1340e11",
|
||||
"3fca84d5",
|
||||
"d9fa78b2",
|
||||
"132f02b1",
|
||||
"b2c96b9c",
|
||||
"91547d93",
|
||||
"c7b1eb29",
|
||||
"7c571bec",
|
||||
"17eac218",
|
||||
"984d6839",
|
||||
"773082a5",
|
||||
"1c2cde82",
|
||||
"58c8b617",
|
||||
"9b4ebce",
|
||||
"99d31081",
|
||||
"2b240e26",
|
||||
"7304fdba",
|
||||
"e2741712",
|
||||
"4cd7c104",
|
||||
"a185efd4",
|
||||
"80139989",
|
||||
"53002ef9",
|
||||
"189c917b",
|
||||
"a02928e1",
|
||||
"f0e16ddf",
|
||||
"585c9ca5",
|
||||
"efb91874",
|
||||
"5e8a975d",
|
||||
"2dad57b0",
|
||||
"a683dc61",
|
||||
"47e069bf",
|
||||
"b6f1a6a0",
|
||||
"b00167ac",
|
||||
"66d6ee0e",
|
||||
"af53f850",
|
||||
"fa070954",
|
||||
"c0ccff8b",
|
||||
"859d68df",
|
||||
"6ca59133",
|
||||
"268cfa01",
|
||||
"e90cb07f",
|
||||
"642a4239",
|
||||
"35237f2c",
|
||||
"749f341f",
|
||||
"e54d1b43",
|
||||
"cee8bb99",
|
||||
"2c28e9e",
|
||||
"1fd1cdbf",
|
||||
"6fa794d8",
|
||||
"94f51eaf",
|
||||
"cc9cf200",
|
||||
"28ed8f10",
|
||||
"1d444116",
|
||||
"e53d0e7d",
|
||||
"49942a76",
|
||||
"b48effd4",
|
||||
"b93de42d",
|
||||
"a3c958fa",
|
||||
"f20325a1",
|
||||
"ab7a7ffe",
|
||||
"409e549f",
|
||||
"121086c6",
|
||||
"f2cec9bd",
|
||||
"bcbe9e9",
|
||||
"1bed9516",
|
||||
"a59402b7",
|
||||
"46342089",
|
||||
"3e240614",
|
||||
"cd3ee220",
|
||||
"c003a828",
|
||||
"c4759a81",
|
||||
"c23cc4e9",
|
||||
"88c92487",
|
||||
"2bbf19e1",
|
||||
"8f72537f",
|
||||
"5ad1f5f8",
|
||||
"dbd8cf8a",
|
||||
"d49960d5",
|
||||
"20f10bb5",
|
||||
"7e878448",
|
||||
"bc437665",
|
||||
"c60e49a3",
|
||||
"fa0d754d",
|
||||
"876e617f",
|
||||
"68eab134",
|
||||
"c7791f0e",
|
||||
"adb3b237",
|
||||
"2bfb918f",
|
||||
"cfaa4e3b",
|
||||
"4c49bbff",
|
||||
"13c5f958",
|
||||
"8ce3d8ef",
|
||||
"74722017",
|
||||
"52f35af",
|
||||
"dc262c5c",
|
||||
"8dcff19f",
|
||||
"a105db7a",
|
||||
"d83eff70",
|
||||
"ee699daf",
|
||||
"11bf6620",
|
||||
"e1319e4d",
|
||||
"142c3f42",
|
||||
"1222dcd4",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"a178d0eb",
|
||||
"7ab826e4",
|
||||
"75b01d53",
|
||||
"91d66590",
|
||||
"5d96c97",
|
||||
"d94b7fdf",
|
||||
"a2fbe5dc",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"d321a44b",
|
||||
"d2738a41",
|
||||
"215ddec8",
|
||||
"786160c7",
|
||||
"5156bc4a",
|
||||
"71bac2ef",
|
||||
"fcf20301",
|
||||
"e7a4de7",
|
||||
"b70d846b",
|
||||
"a96d1f54",
|
||||
"db2e7076",
|
||||
"83d717c1",
|
||||
"86487ff9",
|
||||
"caf922f9",
|
||||
"563c9646",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"18bb0c6b",
|
||||
"21a5ed17",
|
||||
"be1a5fef",
|
||||
"6500ded8",
|
||||
"7151c60d",
|
||||
"cad5e3db",
|
||||
"39b46b96",
|
||||
"7b1b956b",
|
||||
"b9f48f0d",
|
||||
"fcde4fa8",
|
||||
"63858fe7",
|
||||
"62c37e03",
|
||||
"dcc68d38",
|
||||
"c05b151b",
|
||||
"f27584d7",
|
||||
"4fad1acb",
|
||||
"e7ed2735",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"41b64c38",
|
||||
"464d2f7",
|
||||
"149496b0",
|
||||
"612e0799",
|
||||
"5b6054b9",
|
||||
"7f30d025",
|
||||
"25acf372",
|
||||
"c868c1d8",
|
||||
"c8a2c230",
|
||||
"1f26025e",
|
||||
"d6e00fb",
|
||||
"6cfbc148",
|
||||
"887302f8",
|
||||
"d71ea784",
|
||||
"20df2244",
|
||||
"eadf217c",
|
||||
"c6b98365",
|
||||
"a7f09c2a",
|
||||
"c2896b36",
|
||||
"8215313e",
|
||||
"50a4cf7a",
|
||||
"f427d58f",
|
||||
"6da6ea2",
|
||||
"68f0e5a4",
|
||||
"870b6e52",
|
||||
"5f35ea61",
|
||||
"4a16ecb6",
|
||||
"78bd175e",
|
||||
"d8a8fe03",
|
||||
"ca1705f6",
|
||||
"1ee0341d",
|
||||
"9cfa03c3",
|
||||
"eeb8d848",
|
||||
"2df22b80",
|
||||
"f07dd462",
|
||||
"fc0278bc",
|
||||
"315a6427",
|
||||
"c284c1e2",
|
||||
"275045a2",
|
||||
"e64b1da7",
|
||||
"a6a96948",
|
||||
"8e1158d5",
|
||||
"40ceccd2",
|
||||
"dbc64687",
|
||||
"f5586408",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"220477b2"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"1bc79f42",
|
||||
"2a7816d0",
|
||||
"402fa11e",
|
||||
"7513ca34",
|
||||
"237c09e4",
|
||||
"9996f8bb",
|
||||
"a7d3c90c",
|
||||
"51021cd8",
|
||||
"2fdce2b9",
|
||||
"990fb35",
|
||||
"9226e17c",
|
||||
"2ac22e3f",
|
||||
"2a7816d0",
|
||||
"67c64d35",
|
||||
"2c098c19",
|
||||
"430d3bb6",
|
||||
"e145371d",
|
||||
"b01d0407",
|
||||
"fc64d7e1",
|
||||
"6d5f777",
|
||||
"8e3e0656",
|
||||
"2f7161dc",
|
||||
"b5a6400b",
|
||||
"cdaedfdd",
|
||||
"63b4c40c",
|
||||
"5eb994ea",
|
||||
"1429bbb",
|
||||
"cd701f4e",
|
||||
"b89cff83",
|
||||
"c8421ade",
|
||||
"14c62cee",
|
||||
"458ece39",
|
||||
"ade783d0",
|
||||
"f6203d63",
|
||||
"4fa35f68",
|
||||
"48ee6f84",
|
||||
"ea4bcb7",
|
||||
"621fb9ee",
|
||||
"1fcb00fe",
|
||||
"b95b5e39",
|
||||
"d7457dcd",
|
||||
"9b4c98ba",
|
||||
"8f96e1dd",
|
||||
"304a8a2d",
|
||||
"cfae6cf",
|
||||
"2081cd57",
|
||||
"465b855b",
|
||||
"b655ddf9",
|
||||
"6f8a2d11",
|
||||
"2db632c4",
|
||||
"36c8c0ea",
|
||||
"6e22a3bc",
|
||||
"a84eb575",
|
||||
"465b855b",
|
||||
"937cdf14",
|
||||
"6f8a2d11",
|
||||
"e99fee19",
|
||||
"74fe0d93",
|
||||
"ca96c0c7",
|
||||
"41d3eb2f",
|
||||
"27a140c5",
|
||||
"1b41dc29",
|
||||
"1261e1ae",
|
||||
"cd959f60",
|
||||
"77b116c4",
|
||||
"9c5c338",
|
||||
"f83d35b7",
|
||||
"71dc439",
|
||||
"74020a07",
|
||||
"b918ad1e",
|
||||
"5becd493",
|
||||
"d76feec3",
|
||||
"c7c423b0",
|
||||
"913bc548",
|
||||
"83051a0e",
|
||||
"88ec73b",
|
||||
"9402030a",
|
||||
"92b17661",
|
||||
"4667d540",
|
||||
"a94d9764",
|
||||
"5516a422",
|
||||
"3e0e62e7",
|
||||
"4d2e4d01",
|
||||
"bcbfe870",
|
||||
"e1d96a4f",
|
||||
"fbfbbc4d",
|
||||
"109aee33",
|
||||
"f0de185d",
|
||||
"b7d37895",
|
||||
"d0d65788",
|
||||
"8e360be6",
|
||||
"bbea449e",
|
||||
"4451f6c4",
|
||||
"39894194",
|
||||
"b5f935bb",
|
||||
"45d9105",
|
||||
"c2b36c7d",
|
||||
"d9e99c07",
|
||||
"a5a064c3",
|
||||
"3d67c50e",
|
||||
"4c44580c",
|
||||
"56b03a65",
|
||||
"9749898",
|
||||
"4a3351dc",
|
||||
"1fc9ccb4",
|
||||
"39b23284",
|
||||
"b32f33a5",
|
||||
"f2372c57",
|
||||
"1069afeb",
|
||||
"db9ffde1",
|
||||
"8ba31396",
|
||||
"2b9002d4",
|
||||
"21aab5be",
|
||||
"d8781aa",
|
||||
"e8dabfbf",
|
||||
"fa5e5a0a",
|
||||
"651914d9",
|
||||
"b5d55de0",
|
||||
"80d8a846",
|
||||
"9dd1639d",
|
||||
"335ef06d",
|
||||
"331bb270",
|
||||
"6132904b",
|
||||
"de9424b",
|
||||
"d1340e11",
|
||||
"3fca84d5",
|
||||
"d9fa78b2",
|
||||
"132f02b1",
|
||||
"b2c96b9c",
|
||||
"91547d93",
|
||||
"c7b1eb29",
|
||||
"7c571bec",
|
||||
"17eac218",
|
||||
"984d6839",
|
||||
"773082a5",
|
||||
"1c2cde82",
|
||||
"58c8b617",
|
||||
"9b4ebce",
|
||||
"99d31081",
|
||||
"2b240e26",
|
||||
"7304fdba",
|
||||
"e2741712",
|
||||
"4cd7c104",
|
||||
"a185efd4",
|
||||
"80139989",
|
||||
"53002ef9",
|
||||
"189c917b",
|
||||
"a02928e1",
|
||||
"f0e16ddf",
|
||||
"585c9ca5",
|
||||
"efb91874",
|
||||
"5e8a975d",
|
||||
"2dad57b0",
|
||||
"a683dc61",
|
||||
"47e069bf",
|
||||
"b6f1a6a0",
|
||||
"b00167ac",
|
||||
"66d6ee0e",
|
||||
"af53f850",
|
||||
"fa070954",
|
||||
"c0ccff8b",
|
||||
"859d68df",
|
||||
"6ca59133",
|
||||
"268cfa01",
|
||||
"e90cb07f",
|
||||
"642a4239",
|
||||
"35237f2c",
|
||||
"749f341f",
|
||||
"e54d1b43",
|
||||
"cee8bb99",
|
||||
"2c28e9e",
|
||||
"1fd1cdbf",
|
||||
"6fa794d8",
|
||||
"94f51eaf",
|
||||
"cc9cf200",
|
||||
"28ed8f10",
|
||||
"1d444116",
|
||||
"e53d0e7d",
|
||||
"49942a76",
|
||||
"b48effd4",
|
||||
"b93de42d",
|
||||
"a3c958fa",
|
||||
"f20325a1",
|
||||
"ab7a7ffe",
|
||||
"409e549f",
|
||||
"121086c6",
|
||||
"f2cec9bd",
|
||||
"bcbe9e9",
|
||||
"1bed9516",
|
||||
"a59402b7",
|
||||
"46342089",
|
||||
"3e240614",
|
||||
"cd3ee220",
|
||||
"c003a828",
|
||||
"c4759a81",
|
||||
"c23cc4e9",
|
||||
"88c92487",
|
||||
"2bbf19e1",
|
||||
"8f72537f",
|
||||
"5ad1f5f8",
|
||||
"dbd8cf8a",
|
||||
"d49960d5",
|
||||
"20f10bb5",
|
||||
"7e878448",
|
||||
"bc437665",
|
||||
"c60e49a3",
|
||||
"fa0d754d",
|
||||
"876e617f",
|
||||
"68eab134",
|
||||
"c7791f0e",
|
||||
"adb3b237",
|
||||
"2bfb918f",
|
||||
"cfaa4e3b",
|
||||
"4c49bbff",
|
||||
"13c5f958",
|
||||
"8ce3d8ef",
|
||||
"74722017",
|
||||
"52f35af",
|
||||
"dc262c5c",
|
||||
"8dcff19f",
|
||||
"a105db7a",
|
||||
"d83eff70",
|
||||
"ee699daf",
|
||||
"11bf6620",
|
||||
"e1319e4d",
|
||||
"142c3f42",
|
||||
"1222dcd4",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"a178d0eb",
|
||||
"7ab826e4",
|
||||
"75b01d53",
|
||||
"91d66590",
|
||||
"5d96c97",
|
||||
"d94b7fdf",
|
||||
"a2fbe5dc",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"d321a44b",
|
||||
"d2738a41",
|
||||
"215ddec8",
|
||||
"786160c7",
|
||||
"5156bc4a",
|
||||
"71bac2ef",
|
||||
"fcf20301",
|
||||
"e7a4de7",
|
||||
"b70d846b",
|
||||
"a96d1f54",
|
||||
"db2e7076",
|
||||
"83d717c1",
|
||||
"86487ff9",
|
||||
"caf922f9",
|
||||
"563c9646",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"18bb0c6b",
|
||||
"21a5ed17",
|
||||
"be1a5fef",
|
||||
"6500ded8",
|
||||
"7151c60d",
|
||||
"cad5e3db",
|
||||
"39b46b96",
|
||||
"7b1b956b",
|
||||
"b9f48f0d",
|
||||
"fcde4fa8",
|
||||
"63858fe7",
|
||||
"62c37e03",
|
||||
"dcc68d38",
|
||||
"c05b151b",
|
||||
"f27584d7",
|
||||
"4fad1acb",
|
||||
"e7ed2735",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"41b64c38",
|
||||
"464d2f7",
|
||||
"149496b0",
|
||||
"612e0799",
|
||||
"5b6054b9",
|
||||
"7f30d025",
|
||||
"25acf372",
|
||||
"c868c1d8",
|
||||
"c8a2c230",
|
||||
"1f26025e",
|
||||
"d6e00fb",
|
||||
"6cfbc148",
|
||||
"887302f8",
|
||||
"d71ea784",
|
||||
"20df2244",
|
||||
"eadf217c",
|
||||
"c6b98365",
|
||||
"a7f09c2a",
|
||||
"c2896b36",
|
||||
"8215313e",
|
||||
"50a4cf7a",
|
||||
"f427d58f",
|
||||
"6da6ea2",
|
||||
"68f0e5a4",
|
||||
"870b6e52",
|
||||
"5f35ea61",
|
||||
"4a16ecb6",
|
||||
"78bd175e",
|
||||
"d8a8fe03",
|
||||
"ca1705f6",
|
||||
"1ee0341d",
|
||||
"9cfa03c3",
|
||||
"eeb8d848",
|
||||
"2df22b80",
|
||||
"f07dd462",
|
||||
"fc0278bc",
|
||||
"315a6427",
|
||||
"c284c1e2",
|
||||
"275045a2",
|
||||
"e64b1da7",
|
||||
"a6a96948",
|
||||
"8e1158d5",
|
||||
"40ceccd2",
|
||||
"dbc64687",
|
||||
"f5586408",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"220477b2"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,929 @@
|
|||
## 需求探索
|
||||
|
||||
* 功能
|
||||
* 任何参与者都可以编辑/查看文档
|
||||
* 同一文档上的同行更新会自动反映
|
||||
* 如果参与者编辑同一部分,则会解决冲突
|
||||
* 所有参与者最终都应该看到相同的文档修订版
|
||||
* 离线使用?不需要,但如果有时间,我们会讨论
|
||||
* 非功能性
|
||||
* 其他人的更新会实时反映
|
||||
* 支持多达 100 个并发编辑器([Google 也有相同的限制](https://support.google.com/docs/answer/2494822))
|
||||
|
||||
***
|
||||
|
||||
## 背景
|
||||
|
||||
首先,让我们了解一下 Google 文档上的协作编辑会话需要什么。以下是协作编辑会话的观察/要求:
|
||||
|
||||
* **响应式(在延迟方面)**:参与者采取的操作(例如,插入/删除字符、格式化)应立即反映出来。
|
||||
* **实时更新**:同伴采取的操作应几乎立即反映出来。
|
||||
* **容易发生冲突**:在会话期间,由于参与者处理和修改文档的同一部分,因此很有可能发生访问冲突,例如,当另一个参与者正在修改句子时删除该句子。
|
||||
* **分布式**:参与者在自己的机器上访问 Web 应用程序,没有任何地理限制。
|
||||
* **临时**:参与者可以在会话期间自由地来来去去;他们可能随时退出或加入。
|
||||
* **不可预测**:一般来说,参与者没有遵循预先计划的脚本,不可能预测将访问什么信息以及以什么顺序访问。
|
||||
|
||||
这种协作软件的技术术语是“群件系统”。Google Workspace(以前称为 G Suite)中的大多数产品(如 Google Sheets 和 Google Slides)也支持实时协作。
|
||||
|
||||
请注意,参与者应在浏览器选项卡级别考虑。用户可以在两个单独的选项卡上打开同一文档。假设同一用户的浏览器选项卡之间没有直接通信,将每个选项卡视为一个单独的参与者并同步其文档状态会更简单。
|
||||
|
||||
实时协作编辑解决方案的复杂性主要源于通信延迟。理想情况下,如果通信是瞬时的,那么开发一个实时协作编辑器将像创建一个单用户编辑器一样简单,因为来自同伴的更新将显示为由活动用户进行的更新。
|
||||
|
||||
但是,网络延迟限制了通信速度,从而带来了一个根本性的挑战:用户希望他们自己的编辑立即合并到文档中。然而,如果这些编辑立即应用,由于通信延迟,它们必然会插入到文档的不同修订版中。
|
||||
|
||||
考虑以下示例:Alice 和 Bob 从包含单词“Mary”的文档开始。Bob 删除字母“M”然后插入“H”,打算将单词更改为“Hary”。与此同时,Alice 在没有收到 Bob 的编辑的情况下,删除了字母“r”然后删除了“a”,打算将单词更改为“My”。因此,Alice 和 Bob 都将收到应用于他们自己机器上从未存在过的文档版本的编辑,并且在没有任何特殊处理的情况下,文档状态可能会发生分歧。
|
||||
|
||||
因此,实时协作编辑的挑战在于确定如何正确应用来自远程用户的编辑,这些编辑最初是在本地不存在的文档版本中进行的,并且可能与用户自己的本地更改发生冲突。
|
||||
|
||||
通常,对于前端系统设计,我们只需要关心客户端内部发生的事情。但是,对于协作编辑,服务器也在协作协议中发挥着重要作用,因此也有必要讨论服务器的状态和职责。
|
||||
|
||||
***
|
||||
|
||||
## 方法
|
||||
|
||||
通过孤立地查看各个部分来设计整个复杂系统很困难,但如果在没有解释我们如何做出这些决定的情况下向您展示最终架构,也不利于学习。
|
||||
|
||||
因此,我们将讨论系统的各个方面、可以采取的各种方法、涉及的权衡,然后对整体方法做出决定。
|
||||
|
||||
需要注意的是,我们所做的其中一些决定相互依赖,并且只有在作为整体方法的一部分一起使用时,这些决定才有意义。因此,如果您在阅读时想知道我们如何知道某个决定更好,请先阅读,希望后面的部分能证明之前的决定的合理性。
|
||||
|
||||
在确定了整体方法后,我们将深入研究架构、数据模型和 API 以继续进行。
|
||||
|
||||
### 渲染和编辑富文本
|
||||
|
||||
在架构良好的系统中,渲染文档 UI 很大程度上独立于幕后的通信模型和协议。我们在[富文本编辑器系统设计文章](/questions/system-design/rich-text-editor)中更详细地解释了协作编辑器的“前端”。
|
||||
|
||||
以下是构建 Web 富文本编辑器的几种方法的摘要:
|
||||
|
||||
* **带有增强的假光标的 DOM**:使用包含使用 HTML 元素和样式的格式化文本的常规 DOM,并使用假光标进行增强。由于需要计算光标高度和定位,因此这种方法很复杂。
|
||||
* **`contenteditable` 属性**:通过将 `contenteditable` 属性添加到 DOM 元素,其内容变为可编辑,甚至支持用于粗体、斜体、下划线格式的键盘快捷键。但是,它在不同的浏览器中的行为不同,并且格式化选项有限。
|
||||
* **HTML `<canvas>` 元素**:这是一种高级方法,它使用 `<canvas>` 元素并在 canvas 元素内渲染所有内容——文本、布局、光标等。这种方法基本上绕过了浏览器提供的很多东西,并且需要在 canvas 上下文中重新实现所有内容。
|
||||
|
||||
在实践中,Web 上大多数富文本编辑器都是使用 `contenteditable` 属性构建的,并由自定义事件处理程序支持,并允许更多内容元素。
|
||||
|
||||
2021 年,Google [宣布 Google Docs 将转向基于 canvas 的渲染方法](https://workspaceupdates.googleblog.com/2021/05/Google-Docs-Canvas-Based-Rendering-Update.html?utm_source=the+new+stack\&utm_medium=referral\&utm_content=inline-mention\&utm_campaign=tns+platform),以“提高内容在不同平台上的显示性能和一致性”。虽然 Google Docs 使用基于 canvas 的方法,但它过于复杂,大多数前端开发人员对 canvas 也没有太多经验。我们建议首先熟悉 `contenteditable` 属性方法。
|
||||
|
||||
总而言之,Web 上大多数富文本编辑器都使用 `contenteditable` 并采用以下高级方法:
|
||||
|
||||
1. 设计一个特定于编辑器的文档内容模型,通常是基于树的
|
||||
2. 在模型和 DOM 元素之间创建映射
|
||||
3. 定义一组对该模型支持的操作(例如,在某个位置插入文本、删除文本、格式化文本)
|
||||
4. 将用户事件(按键和鼠标点击)转换为一系列这些支持的操作
|
||||
5. 根据这些操作更新 DOM,理想情况下使用最少的 DOM 操作调用
|
||||
|
||||
在[富文本编辑器系统设计文章](/questions/system-design/rich-text-editor)中阅读更多关于协作编辑器的“前端”的信息。
|
||||
|
||||
### 请求负载内容
|
||||
|
||||
当参与者进行更新或从其他人那里收到更新时,请求负载应该包含什么?
|
||||
|
||||
两种常见的方法是:
|
||||
|
||||
* **负载包含整个当前文档**:发送和接收整个当前文档状态。
|
||||
* **负载仅包含更改(增量)**:仅发送和接收所做的更新。
|
||||
|
||||
#### 传输整个文档
|
||||
|
||||
每次更新都传输整个文档具有以下属性:
|
||||
|
||||
* **清晰的文档状态**:接收者知道发送者在更新时正在查看的文档的完整状态。
|
||||
* **发送者的意图不明确**:仅通过接收更新后的文档,接收者并不知道进行了哪些更改,除非他们将其与自己的文档修订版或发送者的先前文档修订版进行比较,这两者都难以计算。
|
||||
* **不可扩展**:随着文档的增长,要传输的数据量将成比例增加。对于大型文档,每个请求将需要更长的传输时间。
|
||||
|
||||
#### 仅传输更改
|
||||
|
||||
仅传输更改具有以下属性:
|
||||
|
||||
* **请求负载小**:由于仅发送更改,请求负载通常很小,通常仅包含命令(例如插入、删除、格式化)和相关元数据(例如要插入/删除的字符和位置)。
|
||||
* **高效的请求负载大小**:文档的长度对请求负载大小没有影响,因为更改不依赖于当前的文档状态。
|
||||
* **没有文档状态信息**:不清楚更改是针对哪个版本的文档进行的。这可以通过在请求负载中包含文档修订号来缓解——稍后会详细介绍。
|
||||
|
||||
#### 请求应该包含什么?
|
||||
|
||||
为了获得实时的 WYSIWIS(所见即所得)体验,需要较短的响应时间(低于 500 毫秒)。 也就是说,当用户进行编辑时,所有其他参与者都应该在 500 毫秒内在其屏幕上看到该更改的反映。 如果参与者看到略有不同或过时的文档版本,则会破坏会话的内聚力。
|
||||
|
||||
参与者所做的更新应以轻量级方式进行通信,因此更新的请求负载应该理想地**仅包含更改**。
|
||||
|
||||
### 网络架构/通信模型
|
||||
|
||||
组建系统参与者之间有两种常见的网络通信模型:
|
||||
|
||||
* **客户端-服务器模型**:中央服务器;所有参与者仅与服务器通信
|
||||
* **对等**:没有中央服务器;所有参与者同时充当服务器和客户端
|
||||
|
||||
#### 客户端-服务器模型
|
||||
|
||||
在客户端-服务器网络模型中,参与者(客户端)从中央服务器请求资源并向其发送负载。 服务器处理这些请求并提供适当的响应。
|
||||
|
||||

|
||||
|
||||
**优点**:
|
||||
|
||||
* **集中式真实来源**:所有更新都必须首先通过服务器。 服务器确定最新的文档修订版并保存真实来源。
|
||||
* **稳健性**:当新参与者加入或现有客户端之一崩溃(由于错误、网络不稳定等)时,可以从服务器获取最新的文档。
|
||||
|
||||
**缺点**:
|
||||
|
||||
* **单点故障**:中央服务器是一个关键组件;如果它发生故障,整个网络可能会中断。
|
||||
* **成本**:由于需要强大的中央服务器,基础设施和维护成本更高。
|
||||
* **可扩展性**:如果客户端数量没有得到适当的扩展,可能会成为瓶颈。
|
||||
|
||||
#### 对等 (P2P) 模型
|
||||
|
||||
在 P2P 网络模型中,每个参与者同时充当客户端和服务器。 对等方直接相互通信,而无需中央服务器。
|
||||
|
||||

|
||||
|
||||
**优点**:
|
||||
|
||||
* **较低的延迟**:每个参与者直接相互通信,而无需中间人,通常会导致较低的延迟。
|
||||
* **具有成本效益**:没有中央服务器,基础设施成本较低。
|
||||
* **负载分配**:当更多参与者加入时,额外的网络负载会在所有参与者之间分配,从而防止瓶颈。
|
||||
|
||||
**缺点**:
|
||||
|
||||
* **同步问题**:如果没有中央机构,参与者很难确定他们的文档版本是否为最新版本以及缺少哪些更新。 每个参与者都需要跟踪对等方的文档状态并确保它们是最新的。
|
||||
* **复杂的共识**:当新参与者加入或现有客户端之一崩溃(由于错误、网络不稳定等)时,新成员必须弄清楚如何在所有对等方之间获取最新的文档修订版。
|
||||
* **仍然需要服务器**:由于文档需要持久保存到数据库中,因此无论如何都需要服务器。
|
||||
* **安全性**:如果参与者变得流氓,所有连接的参与者都将受到影响。
|
||||
|
||||
#### 使用哪种模型?
|
||||
|
||||
尽管客户端-服务器模型似乎优势较少,但服务器上的事实来源是一个重要的功能属性,它决定了整个系统的成败:
|
||||
|
||||
* **数据库持久性**:文档状态必须保存在数据库中。如果没有一个中央服务器来频繁地保存文档,如果客户端在将文档保存到数据库之前断开连接,更新可能会丢失。
|
||||
* **事实来源**:由于新参与者可以随时加入,将事实来源放在服务器上简化了可靠地获取最新文档的逻辑——新参与者可以从一个一致的位置(服务器)获取最新文档。
|
||||
* **优化可靠性和性能**:可靠性和性能是实现无缝协作体验和确保所有参与者都得到更新的最重要因素。在实践中,大多数会话在任何给定时间都不会看到大量参与者(> 20 个);最多只有几个参与者。我们应该优化可靠性和性能,而不是服务器成本。
|
||||
|
||||
考虑到这些原因,首选客户端-服务器模型。P2P 模型更适合视频聊天等应用程序,在这种应用程序中,可以容忍请求丢失,并且并非所有数据都需要持久保存。
|
||||
|
||||
### 并发控制模型
|
||||
|
||||
在讨论协作编辑器时,需要进行两种操作:
|
||||
|
||||
* **本地更新**:用户对其正在查看的文档进行的更新。
|
||||
* **合并来自同伴的更新**:同伴对文档进行的更新。
|
||||
|
||||
所有这些更新都必须合并到当前文档中。如果两个用户正在编辑文档的同一部分(例如,当另一个人正在向句子中添加一个单词时,有人删除了一个句子),则可能会出现冲突。
|
||||
|
||||
并发控制是协调并行运行的干扰操作以保持参与者之间的一致性并解决参与者之间出现的冲突的活动。
|
||||
|
||||
它们可以大致分为乐观型和悲观型:
|
||||
|
||||
* **悲观**:悲观方法保证不会发生冲突。它们的主要目标是避免不一致。它们需要在进行任何数据更改之前与其他站点或中央协调器进行通信。这种通信可以是显式的(例如,楼层控制策略),也可以是隐式的,用户的程序在后台处理它(例如,锁定)。一般来说,悲观方法不需要冲突解决,但网络延迟较高,响应时间较长。
|
||||
* **乐观**:乐观方法不费心避免冲突更新。它们在进行本地更改之前不需要事先通信,并且非常适合高延迟通信通道,因为用户操作的结果可以显示出来,而无需等待通信往返。用户立即应用更改并更新服务器,然后服务器通知同伴。如果多个参与者同时进行更改,则冲突解决算法会创建补偿更改,以确保每个人都达到相同的最终状态。乐观方法具有零/接近零的本地响应时间,但可能需要冲突解决。
|
||||
|
||||
文本编辑不同于传统的表单提交,用户可以容忍更新需要几秒钟才能完成。用户进行的文本更新应立即反映出来,没有任何延迟。为了支持零延迟的本地更新,客户端应在本地维护文档状态的副本,并且用户更新是针对该副本进行的,以便它们可以立即反映在他们的 UI 上。
|
||||
|
||||
让我们看看各种并发控制机制、它们的属性以及它们的优缺点。
|
||||
|
||||
#### 最后写入者胜出模型
|
||||
|
||||
对于传统的 Web 应用程序,如管理仪表板。如果两个用户同时修改同一个实体(例如,更改一个人的姓名),则会发生竞争条件。保存在数据库中的最终名称将是最后到达的请求提交的名称。
|
||||
|
||||
在分布式计算中,这种行为被称为“最后写入者胜出”。显然,“最后写入者胜出”不适用于协作编辑器,至少在文档级别上不适用。它根本不被认为是协作的!
|
||||
|
||||
#### 楼层控制模型
|
||||
|
||||
“楼层控制”是一种协议,它确定哪个用户拥有控制权(拥有楼层)以及在多个人访问资源(在本例中为文档)时如何轮流。
|
||||
|
||||
* **基于令牌**:令牌在参与者之间循环,只有持有令牌的参与者才能进行更改。参与者可以请求令牌,预定义的策略(例如,先到先得或自由竞争)决定是否授予该请求。
|
||||
* **主席控制**:指定的**主席**或**主持人**授予和撤销参与者对资源的访问权限。
|
||||
|
||||
没有控制权的参与者仍然可以看到以实时方式进行的更新。
|
||||
|
||||
楼层控制有助于防止冲突并确保有序交互。显然,这种方法不能满足要求,因为一次只能有一个参与者进行编辑;同伴必须等待轮到他们编辑。解决这个问题的一种方法是允许参与者随时接管令牌的控制权,但这对于仍在打字的参与者来说,突然被剥夺控制权并不是一个很好的用户体验。
|
||||
|
||||
#### 锁模型
|
||||
|
||||
锁定是防止对数据进行未经授权的访问(读取和/或写入)的概念。在协作编辑的上下文中,当编辑者开始编辑文档时,可以锁定文档(或其部分),以防止来自同伴的未经授权的修改。
|
||||
|
||||
关于锁定,有几个问题需要讨论:
|
||||
|
||||
* **锁定粒度**:锁定可以在文档级别、段落级别、句子级别、单词级别等进行。文档级锁定本质上是“楼层控制”机制。细粒度更好,但是当包含句子的段落被删除时,句子级锁定应该发生什么?
|
||||
* **锁定请求**:锁定可以用乐观和悲观两种方式请求。
|
||||
* **悲观锁定**:客户端发出网络请求以获取锁。客户端只有在获得锁后才会开始允许修改。获取锁涉及网络延迟,因此用户在能够执行任何操作之前会遇到延迟,这可能会很烦人。
|
||||
* **乐观锁定**:客户端同时允许修改和请求锁。如果锁的请求被拒绝(可能是因为另一个人也同时请求了锁),客户端将回滚在请求锁之前的更新。
|
||||
* **锁释放**:获取锁后,应该何时释放它?应该在移动光标或按下按键时请求锁吗?例如,如果移动光标时释放锁,则用户可以移动到一个地方复制一些文本,但无法将其粘贴到以前的位置。另一方面,如果锁保留得过于慷慨,即使在用户不再需要锁时,也可能仍然授予用户锁,例如,当用户离开文档但离开键盘时,锁是多余的。确定空闲阈值很棘手。与楼层控制类似,允许用户随时接管锁的控制权是缓解锁释放问题的一种方法。
|
||||
|
||||
锁定是对楼层控制(从技术上讲,它也是一种锁定)的改进,并且在适当的粒度下,通过乐观锁定和适当的锁释放策略是可行的,因为它易于实现。事实上,像 [Quip](https://quip.com/) 这样的协作编辑应用程序在其产品的早期版本中使用了基于锁的并发模型。如果编辑同一部分的可能性很低,锁定实际上是实时协作的可行方法。
|
||||
|
||||
#### 基于事务的模型
|
||||
|
||||
基于事务的方法是一种乐观方法,用户在本地进行更改,并在事务结束时验证更改,类似于 Git 和 Mercurial 等分布式版本控制系统。版本控制系统通过维护不同的版本并允许用户合并更改来管理对文档或资源的更改。
|
||||
|
||||
每个用户在其计算机中都有一个文档副本,并且可以在本地进行更改,而没有任何锁定或其他限制。更改不会立即推送。当用户完成更新后,更改将被提交并推送到服务器。如果存在任何冲突,则事务将回滚,用户必须解决这些冲突才能更新服务器。
|
||||
|
||||
这种方法具有零延迟本地更新的优点,并且所有参与者都可以同时更新他们的文档,而没有任何锁定限制。但是,在这种方法中,更新不是实时的——用户需要显式地推送他们的更新并显式地从其他人那里提取更新。此外,它要求用户自己解决冲突,这很烦人,并且期望普通用户这样做是不合理的。
|
||||
|
||||
#### 版本检测模型
|
||||
|
||||
在版本检测模型中,文档状态在每个用户的机器中复制,参与者可以在本地进行更改,然后尽快将这些更改传播给同伴。在良好的网络连接下,用户应该会在一秒钟内看到来自同伴的更新。
|
||||
|
||||
每个更新请求都包含新数据和更改所依据的文档修订版。当服务器收到更新请求时,它首先检查请求中的修订版是否与当前修订版相同。如果修订版匹配,服务器将更新文档,将其保存为新修订版,并将此信息广播给所有同伴,以便每个人都使用最新修订版。如果它们不同,则存在“版本不匹配”,可以通过以下方式之一解决:
|
||||
|
||||
* **拒绝请求**:这避免了必须进行任何冲突解决,但并不理想,因为当有多个参与者同时更新文档的同一部分时,这种情况发生的频率可能非常高。
|
||||
* **补偿更改**:自动补偿更改以纠正版本不匹配并将系统带入一致状态。在大多数情况下,如果对文档的不同部分进行了更改,补偿是直接的(可能根本不需要补偿!)。如果更改确实发生冲突,我们可以使用冲突解决方法,如操作转换 (OT)。另一方面,如果使用无冲突复制数据类型 (CRDT),则无需补偿。
|
||||
|
||||
具有补偿功能的版本检测模型具有零延迟本地更新的优势,所有参与者都能够同时实时更新他们的文档,没有任何锁定限制,并且如果存在强大的冲突检测和解决方法,最终将收敛到同一版本的文档。
|
||||
|
||||
版本检测胜过基于事务的模型,并满足所有实时协作要求。唯一的问题是——冲突到底是如何解决的?我们将在下面更详细地探讨它们。
|
||||
|
||||
#### 使用哪种并发模型?
|
||||
|
||||
只有**版本检测一致性模型**能够满足所有实时协作编辑要求,因为它具有以下属性:
|
||||
|
||||
* **已复制**:文档在所有参与者的机器中复制。这对于模型进行乐观更新是必要的。
|
||||
* **乐观且非阻塞**:更新在本地进行,无需担心冲突。乐观行为对于本地更新期间的零延迟响应是必要的——您的网络连接的速度或可靠性不会影响用户键入的速度。
|
||||
* **无锁定**:整个文档始终可供每个参与者使用。
|
||||
* **自动冲突解决**:任何检测到的冲突都会在服务器上自动解决。可能的方法包括创建补偿更改并通知客户端 (OT),或使用自动解决冲突的数据结构 (CRDT)。
|
||||
|
||||
从每个参与者的角度来看,它会感觉好像他们正在编辑一个离线 Word 文档——编辑时没有任何延迟。
|
||||
|
||||
### 冲突解决方法
|
||||
|
||||
当两个或多个参与者编辑文档的同一部分时,可能会出现冲突。让我们通过一个例子来运行一下。
|
||||
|
||||
假设我们有一个包含单个单词“ABCDE”的文档,Alice 和 Bob 同时编辑它:
|
||||
|
||||
1. Alice 删除第四个字符“D”。她的计算机发送请求`DEL @3`,以指示删除第四个字符,该字符位于索引 3(从零开始索引)。
|
||||
2. Bob 删除第二个字符“B”。他的计算机发送请求`DEL @1`,以指示删除第二个字符,该字符位于索引 1(从零开始索引)。
|
||||
|
||||
服务器将处理这两个请求。目前,服务器只是执行命令并转发它们,没有任何特殊处理)。在两次删除后,预期的最终结果是“ACE”。
|
||||
|
||||
由于网络延迟是不可预测的,可能发生以下两种情况:
|
||||
|
||||

|
||||
|
||||
1. **Alice first**:服务器先收到 Alice 的请求,然后是 Bob 的请求,导致服务器删除第四个字符,然后删除第二个字符“ABCDE” -> “ABCE” -> “ACE”。在这种情况下,服务器上的文档正确地删除了 Alice 和 Bob 想要删除的字符。服务器最终得到“ACE”。
|
||||
2. **Bob first**:服务器先收到 Bob 的请求,然后是 Alice 的请求,导致服务器删除第二个字符,然后删除第四个字符:“ABCDE” -> “ACDE” -> “ACD”。请注意,当服务器处理 Alice 的请求时,文档的第四个字符现在是“E”,但 Alice 想删除“D”。服务器最终得到“ACD”。
|
||||
|
||||
在这种幼稚的方法中,根据服务器首先收到谁的请求,服务器最终会得到不同的文档状态。
|
||||
|
||||
事实上,无论谁的请求先到达服务器,Bob 最终都会得到“ACD”。请记住,更新首先在本地进行,然后发送到服务器以广播给其他人。问题在于偏移量取决于编辑时文档的状态。请求有效负载包括偏移量,但不包括它们所依赖的上下文,这会导致差异。
|
||||
|
||||
这只是可能导致冲突的场景之一。还有其他可能的组合,如插入 + 插入、删除 + 插入、删除 + 删除等。
|
||||
|
||||
#### 冲突解决属性
|
||||
|
||||
因此,我们需要一种尽可能遵守这些属性的冲突解决方法:
|
||||
|
||||
* **收敛性**:所有副本最终将达到相同的状态,前提是它们已收到并应用了相同的操作集(静止状态)。
|
||||
* **因果关系保留**:确保操作的顺序尊重它们之间的因果关系。例如,如果一个操作在逻辑上跟随另一个操作,则系统必须按该顺序应用这些操作以保持一致性(例如,在插入字符后删除字符)。
|
||||
* **意图保留**:确保在合并并发操作后保留操作的原始意图。这意味着合并并发操作的结果应与用户期望其操作实现的目标保持一致,即使存在冲突。例如,Alice 将整个句子设为粗体,而 Bob 同时向句子中添加一个单词,最终结果应该是包含 Bob 单词的句子为粗体。
|
||||
|
||||
使用了两种常见的冲突解决方法:
|
||||
|
||||
* **操作转换 (OT)**:OT 考虑编辑时的上下文并相应地转换操作(例如,通过修改插入/删除的偏移量)。
|
||||
* **无冲突复制数据类型 (CRDT)**:CRDT 强制使用数据结构,其中更新是可交换和可结合的,因此操作的顺序无关紧要。
|
||||
|
||||
**注意**:详细解释每种冲突解决方法超出了本文的范围(无论如何,在面试期间没有足够的时间)。但是,您应该能够使用一个示例来解释一般原则。我们将解释每种方法的工作原理,并提供示例和指向资源的链接,供您进一步阅读。
|
||||
|
||||
#### 操作转换 (OT)
|
||||
|
||||
OT 最初是为协作编辑文本文档而开发的,允许多个用户同时编辑而不会发生冲突。它通过根据其他并发操作的上下文转换操作来保持不同客户端之间文档状态的一致性。
|
||||
|
||||
OT 系统通常使用复制的文档存储模型,其中每个客户端都维护文档的本地副本。用户在其本地副本上操作,更改会传播到其他客户端。当客户端收到来自另一个客户端的更改时,它会应用转换函数以确保本地文档与更新保持一致。
|
||||
|
||||
OT 的关键组成部分包括:
|
||||
|
||||
1. **操作**:这些是用户执行的基本操作,例如插入、删除或修改文本。每个操作都与文档中的一个位置相关联。
|
||||
2. **转换函数**:这些函数确定如何在发生冲突时调整操作。例如,如果两个用户在同一位置插入文本,则转换函数将解决冲突以保持一致的文档状态。
|
||||
3. **控制算法**:这些算法管理转换的顺序和上下文。它们根据操作的因果关系决定哪些操作应该与其他操作进行转换,确保操作的顺序尊重每个用户的意图。
|
||||
|
||||
让我们回顾一下 Alice 和 Bob 的例子,看看应用 OT 如何解决这个问题。
|
||||
|
||||

|
||||
|
||||
1. **Alice 在 Bob 之前**: 服务器在 Bob 之前收到 Alice 的请求,导致服务器删除第四个字符,然后删除第二个字符 "ABCDE" -> "ABCE" -> "ACE"。在这种情况下,服务器上的文档正确地删除了 Alice 和 Bob 想要删除的字符。但是,Bob 的计算机意识到 Alice 的更改是在 Bob 的删除之前进行的,因此它将 Alice 的更改从 `DEL @3` -> `DEL @2` 转换,因为 Bob 删除了一个较早的字符。Bob 最终得到 "ACE"。
|
||||
2. **Bob 在 Alice 之前**: 服务器在 Alice 之前收到 Bob 的请求,导致服务器删除第二个字符:"ABCDE" -> "ACDE"。当服务器收到 Alice 的请求时,它意识到 Alice 的更改是在 Bob 的删除之前进行的,因此它将 Alice 的更改从 `DEL @3` -> `DEL @2` 转换,以考虑 Bob 删除较早字符的情况。服务器正确地删除了 "D"(第三个字符),并将此转换后的操作传递给 Bob。服务器和 Bob 最终都得到 "ACE"。
|
||||
|
||||
服务器和客户端都可以执行 OT,并且必须处理插入、删除和格式更改可以相互配对和转换的各种方式。上面的例子展示了如何将删除转换为删除。转换的其他一些例子:
|
||||
|
||||
* 格式化在针对插入进行转换时会扩展:`FORMAT BOLD @10-20` 转换为 `INS "ABC" @15` 结果为 `FORMAT BOLD @10-23`。
|
||||
* 并非所有更改都会冲突,在这些情况下,不需要转换。例如 `FORMAT BOLD @10-20` 和 `FORMAT ITALIC @15-25` 不会冲突,因为文本可以同时是粗体和斜体。
|
||||
|
||||
服务器如何知道 Alice 和 Bob 的请求是否在考虑了另一方的更改的情况下发出的,以及是否需要进行任何转换?每次在服务器上进行更新并修改文档时,文档都会保存为一个新的修订版本(例如,使用时间戳或 [单调](https://en.wikipedia.org/wiki/Monotonic_function) 递增的整数)。时间戳不是一个好的选择,因为机器时间可以被操纵,这会导致按时间排序时出现错误的顺序,更喜欢单调递增的正整数。
|
||||
|
||||
请求和响应可以包括文档修订号,以便服务器和客户端都知道另一方在发出请求时看到的文档版本,并且可以正确确定操作是否需要转换。
|
||||
|
||||
**OT 分析**:让我们看看 OT 如何满足冲突解决属性:
|
||||
|
||||
* **收敛**:OT 使用转换函数来修改操作,以便它们可以在所有副本中一致地应用,即使它们以不同的顺序到达。核心思想是,如果两个操作冲突,转换函数会调整一个或两个操作,以确保它们可以按任何顺序应用,但仍然导致相同的最终状态。
|
||||
* **因果关系保留**:OT 系统通常使用因果历史跟踪来确保操作以尊重其因果依赖关系的顺序应用。这通常通过使用元数据(例如时间戳或修订号)标记操作来完成,这些元数据指示它们的因果关系。
|
||||
* **意图保留**:OT 负载通常包括命令意图和上下文(文档修订号)。转换函数考虑了最初应用操作的上下文。命令以及上下文感知有助于在文档由于其他并发操作而发生更改时保留原始意图。
|
||||
|
||||
OT 适用于基于文本的文档,但对于其他类型的数据结构(例如分层或非线性数据)可能效果较差。为这些类型的数据实现 OT 需要额外的努力和定制。
|
||||
|
||||
OT 算法和各种一致性模型有很多实现,这超出了本文的范围。如果您有兴趣,请查看以下链接:
|
||||
|
||||
* [新的 Google 文档有什么不同:冲突解决](https://drive.googleblog.com/2010/09/whats-different-about-new-google-docs_22.html)
|
||||
* [操作转换作为自动冲突解决的算法](https://medium.com/coinmonks/operational-transformations-as-an-algorithm-for-automatic-conflict-resolution-3bf8920ea447)
|
||||
* [使用中央服务器的 OT 可视化](https://operational-transformation.github.io/)
|
||||
|
||||
#### 无冲突复制数据类型 (CRDTs)
|
||||
|
||||
如果您想知道——为什么在更新时使用偏移索引,因为它们与当前文档状态高度耦合,并且需要经历使用像 OT 这样的复杂算法来解决冲突的麻烦?为什么不使用不同的有效负载或数据结构来提供有关更新的更多信息,并使合并更新更容易?这正是无冲突复制数据类型 (CRDTs) 的目标。
|
||||
|
||||
CRDT 是为分布式系统设计的先进数据结构,允许多个用户或应用程序并发更新共享数据,而无需协调,当所有更新都已收到并应用时,最终会收敛到相同的状态(强最终一致性)。CRDT 不是使用 OT 解决冲突,而是构建 CRDT,以便对数据执行的操作本质上是无冲突的,或者可以以一致的方式自动解决。
|
||||
|
||||
CRDT 具有以下属性:
|
||||
|
||||
1. **并发更新**:CRDT 允许跨多个数据副本进行独立更新。每个副本都可以被修改,而无需与其他副本协调,这使得它们非常适合网络连接可能间歇的环境。可以使用 gossip 协议传播更新,而无需中央机构。
|
||||
2. **单调递增更新**:对 CRDT 的更新必须是单调的,确保新值始终大于或不同于先前的值。这允许状态变化的清晰进展。
|
||||
3. **可交换和关联操作**:CRDT 中的操作必须是可交换的(顺序无关紧要 -> `A + B + C === C + B + A`),关联的(分组无关紧要 -> `(A + B) + C === A + (B + C)`)。这确保了所有副本,即使它们以不同的顺序接收操作或在不同的时间合并状态,最终也会处于相同的状态。
|
||||
4. **自动冲突解决**:CRDT 包含预定义的算法(例如,最后写入者获胜),这些算法会自动解决可能因并发更新而引起的不一致性。这意味着即使不同的副本进行了冲突的更改,CRDT 也可以在没有手动用户干预的情况下合并这些更改。
|
||||
5. **最终一致性**:尽管副本在任何时间点都可能具有不同的状态,但 CRDT 保证所有副本最终将收敛到相同的最终状态,一旦所有更新都已传播,无论接收这些更新的顺序如何。这通常被称为“强最终一致性”,它确保不保留不一致的状态。
|
||||
|
||||
CRDT 模型有点类似于 Git——组织中的每个开发人员都拥有存储库的副本,并被允许在本地进行更改。最后,开发人员可以根据自己的喜好与其他每个开发人员合并更改:成对、循环或通过中央存储库。一旦所有合并完成,每个开发人员都将拥有相同的状态。但是,与 Git 不同的是,CRDT 规定了一种自动合并冲突的方式,并且可以合并无序更改。
|
||||
|
||||
**CRDT 示例 – 仅增长集**:CRDT 的一个示例是仅增长集。仅增长集是一个无序集,仅允许添加唯一元素。它是一个 CRDT,因为:
|
||||
|
||||
* 该集合可以被复制。
|
||||
* 每个副本都可以添加它喜欢的任何元素,并且添加是单调递增的更新。
|
||||
* 每个副本都可以按任何顺序合并在一起。
|
||||
* 一旦所有合并完成,所有副本将具有相同的内容(所有单个集合的并集)。
|
||||
|
||||
**在 CRDT 中表示文本**:可以使用序列 CRDT(如列表(例如 [线性序列和可复制增长数组](https://www.bartoszsypytkowski.com/operation-based-crdts-arrays-1/)))和树来表示协作文本文件。 毫不奇怪,文本 CRDT 的实现比仅增长集合更复杂。 [Cola](https://nomad.foo/blog/cola) 是用 Rust 编写的文本 CRDT,但该数据结构可以用任何语言实现。
|
||||
|
||||
{/* TODO: 给出列表 CRDT 的结构作为示例 */}
|
||||
|
||||
文本编辑也涉及删除。 我们如何在 CRDT 中表示删除? 诀窍是通过使用两个单独的仅增长数据结构来跟踪删除,一个用于跟踪插入,另一个用于跟踪删除(称为墓碑标记)。 结果值是插入集中的项目减去删除集中的项目。 复杂的 CRDT 通常由较小的 CRDT 组合而成,这极大地有助于保留 CRDT 属性。
|
||||
|
||||
**CRDT 分析**:让我们看看 CRDT 如何满足冲突解决属性:
|
||||
|
||||
* **收敛**:CRDT 操作被设计为可交换和关联的,这意味着应用操作的顺序和分组不会影响最终状态。 这是所有副本收敛到相同状态的关键原因。
|
||||
* **因果关系保留**:操作以尊重因果关系的方式应用于其他副本。 例如,操作可以按任何顺序传播,但操作可能会被缓冲,直到所有先前的因果相关操作都已应用。 例如,删除只有在插入发生后才会生效。 在 Last-Writer-Wins Register (LWW-Register) 中,更新会标记时间戳,确保最近的更新(根据因果关系)优先。
|
||||
* **意图保留**:CRDT 包含预定义的冲突解决策略,旨在保留用户意图。 这些策略通常是特定于应用程序的,并确保合并状态反映所有并发操作的组合意图。 CRDT 操作的特定语义旨在确保当两个操作冲突时,解决方案保留每个操作的最有意义的方面。 但是,存在一定程度的主观性,并且高度依赖于实现和用例。
|
||||
|
||||
**CRDT 的缺点**:虽然与 OT 相比,CRDT 更现代化,但它确实存在一些缺点:
|
||||
|
||||
* **元数据开销**:CRDT 经常需要额外的元数据来跟踪操作、修订或唯一标识符。 这种元数据会随着时间的推移而增长,从而导致存储需求增加,尤其是在大型系统或复杂数据类型中。
|
||||
* **不断增加的大小**:CRDT 具有单调递增的状态,通常必须跟踪未出现在最终视觉结果中的删除。 这意味着数据只会随着时间的推移而增长。 可以使用垃圾收集或清理机制,但如果没有导致副本不一致,则它们在技术上可能难以实现。
|
||||
* **冲突解决**:虽然 CRDT 旨在通过以预定义的方式合并并发更新来解决冲突,但这种自动冲突解决可能并不总是与所需的应用程序语义保持一致,从而导致意外结果。
|
||||
|
||||
CRDT 完全绕过了对因果关系保留的需求,因为更新可以按任何顺序合并,并且仍然会收敛到相同的结束状态。 CRDT 是否可以保留意图取决于所选的冲突解决策略和实现。
|
||||
|
||||
#### 使用哪种冲突解决方法?
|
||||
|
||||
OT 和 CRDT 都旨在以在所有副本中保持一致性的方式管理对共享数据的并发更新,但它们使用不同的方法,并具有不同的权衡。
|
||||
|
||||
**收敛**:OT 可以保证收敛,但需要更仔细地处理操作顺序和上下文。 它通常需要一个中央服务器或更紧密协调的通信协议来确保一致性。 CRDT 旨在保证副本之间的最终一致性,并且只要它们收到所有更新,就会收敛到相同的状态。 CRDT 在这里更胜一筹,因为它具有更强的收敛保证。
|
||||
|
||||
**技术复杂性**:为复杂或分层数据结构实现 CRDT 具有挑战性,需要仔细设计以确保操作保持所需的属性。 实现 OT 也很复杂,尤其是在设计必须处理所有可能的冲突和并发操作的转换函数时。 尽管 CRDT 的一致性模型更容易理解,但为文本结构理解和实现 CRDT 却更难。 在文本编辑的背景下,OT 占据主导地位。
|
||||
|
||||
**生态系统**:OT 是一项成熟的技术,已在几个著名的协作编辑系统中实现,并且有许多成熟的库和工具可用——Google Docs 本身使用 OT。 CRDT 是后起之秀,但多年来一直受到很好的研究,并且已经创建了许多实现 CRDT 的库。 协作设计编辑软件 Figma 使用 CRDT。 就生态系统而言,没有明确的赢家,因为这两种方法都得到了很好的研究(并且受到了批评)。
|
||||
|
||||
总的来说,没有明显更好的选择。 CRDT 和 OT 都可以用于实现协作编辑器。 CRDT 是通用的,而 OT 源于文档编辑。 虽然 CRDT 较新且更流行,但 OT 已经成熟,并且在低延迟、即时反馈和对用户意图的精细控制至关重要的实时协作编辑应用程序中表现出色。
|
||||
|
||||
本文的其余部分假定使用 OT 作为冲突解决方法。 主要原因是 Google Docs 本身是使用 OT 实现的,因此在实现使用 OT 而不是 CRDT 的协作文本编辑器方面也有更多资源。 解释 OT 的工作原理也比文本 CRDT 容易,文本 CRDT 具有非常复杂的结构。
|
||||
|
||||
### 协作协议
|
||||
|
||||
我们已经讨论了冲突解决方法,但这只是故事的一部分。 还有其他与使用 OT 进行协作编辑相关但未解答的问题:
|
||||
|
||||
* **请求调度**:何时发送更新请求? 每次按键、用户停止输入后或其他? 如果用户进行多次连续更新,是否应该在进行时将它们全部发送到服务器,或者是否有更好的方法来调度它们?
|
||||
* **多个参与者**:上面的 OT 示例演示了转换如何适用于两个参与者。 当会话中有两个以上的参与者并且必须针对多个对等方转换操作时会发生什么情况?
|
||||
* **参与者加入**:加入编辑会话的中间用户如何开始更新他们的副本并从其他人那里接收更新?
|
||||
|
||||
可以设计协作协议来回答这些问题。 在继续阅读之前,尝试一下这个 [具有中央服务器的 OT 交互式可视化](https://operational-transformation.github.io/) 可能会有所帮助。
|
||||
|
||||
#### 中央服务器
|
||||
|
||||
虽然 OT 在其核心不需要中央服务器,但拥有中央服务器可以简化某些事情。
|
||||
|
||||
一个中心服务器架构使客户端可以轻松地与服务器保持同步。服务器的作用是接收更新并充当文档状态的权威,当客户端发送与最新文档冲突的请求时转换操作。如果有效负载无效,或者操作是在过时的文档修订版上进行的,或者操作太难转换,或者要转换的操作太多,服务器也可以拒绝操作。
|
||||
|
||||
当服务器收到更新请求时,它将向连接的客户端广播更新。客户端不需要关心有多少其他连接的客户端。它依靠服务器来通知自己要进行的更改。从客户端的角度来看,拥有 N 个对等客户端(其中 N 是对等客户端的数量)每秒轮流进行更新,这等同于单个对等客户端每秒进行更新。对于客户端来说,拥有一个对等客户端与拥有十个对等客户端相同。这使我们能够通过在每个客户端-服务器对之间运行 N 个独立的双向同步来实现 N 路同步。客户端只需专注于与服务器的同步,服务器可以平等地对待所有客户端;没有需要考虑的特殊客户端。
|
||||
|
||||
中途加入的参与者将在请求时获得该文档的版本。从那时起,他们就是一个连接的客户端,并且可以像任何其他参与者一样发送和接收更新,只要更新是在下载初始文档后按顺序接收的。
|
||||
|
||||
#### 将文档存储为修订日志
|
||||
|
||||
该文档可以存储为仅追加操作/更改的日志,类似于 [Event Sourcing pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing)。可以通过从开始到该点的操作重放来重建文档的任何修订版。因此,可以通过重放自开始以来的所有操作来获得文档的最新修订版。这种存储数据的方法也允许客户端查看文档的版本历史记录。
|
||||
|
||||
| 修订版号 | 用户 | 操作 | 时间戳 | 文档状态(未存储) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | N/A | N/A | 2024-08-03 10:00 | \<EMPTY> |
|
||||
| 1 | Charlie | `INS "Hello" @0` | 2024-08-03 10:05 | "Hello" |
|
||||
| 2 | Alice | `INS " world" @5` | 2024-08-03 10:10 | "Hello world" |
|
||||
| 3 | Bob | `INS "!" @11` | 2024-08-03 10:15 | "Hello world!" |
|
||||
|
||||
请注意,文档状态未存储在每个日志条目中,因为它可以从前面的操作中导出。
|
||||
|
||||
存储为日志是必要的,因为某些客户端可能非常过时,需要赶上当前文档。想象一下用户断开了协作会话并保持选项卡打开的情况。当他们第二天重新连接并且其他人自那时以来进行了更新时,在重新连接时,客户端会发送其当前过时的文档修订号,这允许服务器确定自该文档修订版以来的细粒度更改,并使用要对客户端执行的操作列表进行响应,这也可能需要转换。
|
||||
|
||||
加入非空文档的编辑会话的客户端不需要从头开始获取整个日志;服务器会响应文档的当前状态。后续文档修订版可以通过将新操作(本地和来自对等方)应用于初始会话文档状态来计算。
|
||||
|
||||
上面没有演示,但每个日志条目也可以包含多个操作。
|
||||
|
||||
#### 何时发送更新请求
|
||||
|
||||
何时应该发送更新请求?应该在每次按键时、用户停止输入后或其他时间发送更新请求吗?
|
||||
|
||||
* **按键**: 每次按键发送一个请求。不太好,因为它可能会给服务器带来负担,并创建许多潜在的冗余操作日志。
|
||||
* **去抖动**: 在用户停止输入一小段时间(例如 300 毫秒)后发送一个请求。对于不停止打字的用户来说,这可能并不理想,因为如果他们的浏览器在更新发送出去之前崩溃,他们可能会丢失数据。
|
||||
* **节流**: 在连续打字期间每隔固定时间间隔(例如 300 毫秒)发送一个请求。似乎可行,但确定节流持续时间值很棘手。
|
||||
|
||||
上述策略均不可行,因为它们都允许用户并行发送请求,这意味着多个请求同时处于传输中。这是一个问题,因为不能保证服务器会按发送顺序接收请求,这可能导致竞争条件和不可能的操作。
|
||||
|
||||
有一些方法可以解决乱序请求,但它们并不简单。客户端可以为会话中的每个请求包含一个单调递增的整数,但服务器必须跟踪到目前为止发送的最后一个请求,如果它收到乱序请求,则推迟未来的请求,然后等待丢失的请求(缓冲未来的操作)。
|
||||
|
||||
Google Docs 采用的一种优雅方式是确保每个用户最多只有一个正在传输的更新请求(也称为待处理),方法是使用本地更新缓冲区(队列)。在以下情况下,将清除本地更新缓冲区并将缓冲区中的操作发送到服务器:
|
||||
|
||||
* 如果没有待处理的请求,则在短时间空闲后(约 200 毫秒)
|
||||
* 如果有待处理的请求,则在待处理的请求返回时
|
||||
|
||||
当用户开始输入时:
|
||||
|
||||
1. 用户操作在客户端计算机上本地反映并添加到本地更新缓冲区中
|
||||
2. 短时间后,发出一个请求,其中包含本地更新缓冲区中的操作
|
||||
3. 发送请求后,清除本地更新缓冲区,并且在有待处理请求时进行的任何新的本地更新都将添加到本地更新缓冲区中
|
||||
4. 如果本地更新缓冲区不为空,则仅在收到服务器对当前请求的响应后,才发送下一个请求,即使空闲时间已过
|
||||
5. 这将重复,直到本地更新缓冲区为空(当用户停止输入时)并且没有更多操作要发送到服务器
|
||||
|
||||
**快速与慢速连接**:由于只能有一个待处理的更新请求,因此请求的频率高度依赖于客户端的连接速度。
|
||||
|
||||

|
||||
|
||||
* **快速连接**:网络连接速度更快的客户端将更快地发送请求和接收响应,从而导致更频繁的请求,并且每个请求包含的负载更小。本地更新缓冲区将更频繁地被清除。
|
||||
* **慢速连接**:网络连接速度较慢的客户端将看到较少的请求,并且每个请求将包含更大的负载。本地更新缓冲区将不那么频繁地被清除。
|
||||
|
||||
通过使用本地更新缓冲区来保存本地操作,并且每个客户端只有一个待处理的请求,客户端可以确保操作按顺序发送,并且服务器可以在收到客户端请求后立即处理它,因为服务器知道来自客户端的每个请求都是最新的。
|
||||
|
||||
#### 操作粒度
|
||||
|
||||
合适的操作粒度是既不会导致太多操作,又允许区分意图的粒度。考虑以下场景:
|
||||
|
||||
* 如果用户正在连续打字,那么进行一个包含多个字符的插入操作,而不是每个字符进行一个插入操作,会更有效率。
|
||||
* 如果用户按住退格键并删除多个字符,那么进行一个包含已删除字符的删除操作,而不是每个字符进行一个删除操作,会更有效率。
|
||||
* 如果用户输入一些字符,意识到有拼写错误,删除错误的字符,然后再次输入,这些操作构成“连续打字”,可以合并成一个插入操作。在这种情况下,当操作发送到服务器时,服务器根本看不到任何删除。
|
||||
|
||||
选择了以下准则:
|
||||
|
||||
* **连续范围的合并**:连续范围上的操作可以合并/合并成一个相同类型的操作。
|
||||
* **每个意图一个操作**:每个意图(插入、删除、格式化)将是一种不同的操作,除非它们是连续打字的一部分。
|
||||
* **连续打字操作的合并**:连续打字事件包括正向打字、向后删除和向前删除。
|
||||
|
||||
#### 更新请求负载
|
||||
|
||||
虽然没有明确提及,但每个更新负载可以包含多个操作。在请求往返时间较慢的网络连接上,用户有更大的窗口进行本地更新,有时这可能包括不同类型的操作,而不仅仅是插入。
|
||||
|
||||
在早期的网络图中,更新请求只显示单个操作,但 Google Docs 上的请求实际上包含一个操作数组,即更新缓冲区中的所有操作。服务器按顺序迭代操作数组,并在必要时转换它们。
|
||||
|
||||
通过允许更新请求包含一个操作数组,缓冲区被更频繁地清除,并且丢失未发送更改(可能由于崩溃或关闭选项卡)的可能性更低。
|
||||
|
||||
#### 将它们放在一起
|
||||
|
||||
我们已经在上面解释了客户端-服务器架构如何能够很好地扩展以支持大量参与者。因此,我们可以专注于客户端和服务器之间的通信以及每个跟踪的信息。
|
||||
|
||||
每个客户端跟踪以下信息:
|
||||
|
||||
1. **最新文档修订版**:从服务器发送到客户端的最新修订版的标识符。Google Docs 使用单调递增的整数作为文档修订版 ID/编号。此值包含在每个更新请求的请求负载和响应中。
|
||||
2. **本地更新缓冲区**:已在本地进行但尚未发送到服务器的更改(操作)。这是前面解释的更新缓冲区。
|
||||
3. **已发送更新缓冲区**:已在本地进行、已发送到服务器但尚未被服务器确认的更改(操作)。跟踪已发送的操作是必要的,因为请求可能会失败,并且如果请求失败,客户端应该重试该请求。
|
||||
4. **更新日志**:自初始化文档修订版以来文档的已提交更改(操作)。这些更改可能是用户已确认的更新,也可能是服务器推送到客户端的对等更新。这些操作可能已经在服务器上进行了转换,或者根据更新的顺序在客户端上进行了转换。
|
||||
5. **初始文档状态**:客户端首次加入编辑会话时文档的状态。
|
||||
6. **文档状态**:客户端上显示的文档的当前状态。这可以从初始文档状态、接收到的更新、更新缓冲区和待处理更新中计算得出。
|
||||
|
||||
请注意,本地更新缓冲区和待处理更新缓冲区中的操作可能会根据从服务器接收到的更新内容进行转换。客户端的责任是:
|
||||
|
||||
* 跟踪本地更新的状态 – 将它们发送到服务器并在适当的时候将它们移动到已发送更新缓冲区。
|
||||
* 在已发送更新被确认后,将已发送更新移动到更新日志。
|
||||
* 从服务器接收更新并转换任何相关的本地更新。
|
||||
* 将初始文档状态与更新日志相结合,以计算最新的文档状态并显示它。
|
||||
|
||||
服务器包含以下信息:
|
||||
|
||||
1. **待处理更新队列**:从客户端接收到的尚未处理的所有更改(操作)的列表。
|
||||
2. **修订日志**:提供文档完整历史记录的处理更改列表。
|
||||
3. **文档状态**:截至上次处理更改时文档的当前状态。可以通过重放自开始以来的所有更改来派生此值,但会对其进行计算和缓存,以便可以立即将其发送给加入会话的新客户端。每当处理新更改时,它都会被重新计算。
|
||||
|
||||
服务器的职责是:
|
||||
|
||||
* 当客户端加入编辑会话时,向客户端发送文档的最新状态。
|
||||
* 转换客户端的更新,如果它们最初是在过时的修订版本上进行的。
|
||||
* 向其他客户端广播更新。
|
||||
|
||||
*来源:[What’s different about the new Google Docs: Making collaboration fast](https://drive.googleblog.com/2010/09/whats-different-about-new-google-docs.html)*
|
||||
|
||||
让我们通过一个实际的例子来演示服务器如何与客户端一起使用操作转换来实现实时协作编辑。
|
||||
|
||||

|
||||
|
||||
1. Alice、Bob 和服务器从一个空文档开始
|
||||
2. Alice 键入“Hello”,插入操作被添加到她的“本地更新”中。Alice 在她的文档副本中立即看到“Hello”
|
||||
3. Alice 插入“Hello”被发送到服务器,并从“本地更新”移动到“已发送更新”
|
||||
4. 服务器接收到请求,并将 Alice 的插入操作添加到其“待处理更新”队列中
|
||||
5. 同时,Alice 键入字符“ world”。此插入被添加到“本地更新”中,但在“已发送更新”为空之前不会发送到服务器
|
||||
6. 服务器处理 Alice 的第一次插入并更新其文档状态。然后它向 Alice 发送一个确认
|
||||
7. Alice 从“已发送更新”中删除该操作,并将其最新修订号更新为 1
|
||||
8. 服务器将 Alice 的插入广播给 Bob,Bob 将该操作应用于他的文档副本,并将其最新修订号更新为 1
|
||||
|
||||

|
||||
|
||||
9. Alice 的第二次插入“ world”现在可以处理。该操作被发送到服务器,并从“本地更新”移动到“已发送更新”
|
||||
10. 服务器接收到 Alice 的第二个请求,并将她的操作添加到其“待处理更新”队列中
|
||||
11. 同时,Bob 在“Hello”的末尾插入一个“!”字符
|
||||
12. Bob 插入“!”被发送到服务器并添加到服务器的“待处理更新”队列中。Bob 将操作从“本地更新”移动到“已发送更新”
|
||||
13. 服务器首先处理 Alice 的第二次插入,并向 Alice 发送一个确认
|
||||
14. Alice 从“已发送更新”中删除该操作,并将其最新修订号更新为 2
|
||||
15. 服务器将 Alice 的插入广播给 Bob。但是,Bob 有未提交的更新,因此他需要根据 Alice 的更新来转换它们。Bob 插入“!”被转换为第 11 个位置,以便为 Alice 的“ world”腾出空间。Bob 将其最新修订号更新为 2
|
||||
16. 服务器接下来处理 Bob 的插入。它看到 Bob 的操作是针对修订版 1 制作的,这并没有考虑到 Alice 的第二次插入。因此,服务器将 Bob 插入“!”转换为第 11 个位置,以便为 Alice 的“ world”腾出空间。这种转变与 Bob 的客户端首次收到 Alice 插入“ world”时所做的转变相同
|
||||
17. 服务器处理 Bob 的转换后的插入,并向 Bob 发送一个确认。Bob 从“已发送更新”中删除该操作,并将其最新修订号更新为 3
|
||||
18. 服务器将 Bob 的插入广播给 Alice。它已经被转换,因此 Alice 可以简单地应用该操作并将其最新修订号更新为 3
|
||||
|
||||
Alice、Bob 和服务器最终都得到了相同版本的文档。
|
||||
|
||||
**注意**:Alice 和 Bob 的更新日志未在图中显示,但它们反映了服务器的修订日志。
|
||||
|
||||
### 传输机制
|
||||
|
||||
到目前为止讨论的协作方法不限制或规定任何传输机制。但是,所选传输机制需要满足某些要求:
|
||||
|
||||
* **双向**:服务器需要能够主动向对等客户端发送数据,只要收到来自客户端的更新。
|
||||
* **低延迟**:尽管用户编辑首先在本地进行并立即显示,并且更新有效负载很小,但低延迟传输机制将帮助用户更频繁地接收对等更新并保留更改,从而降低因崩溃而导致数据丢失的可能性。
|
||||
* **有序**:服务器假定广播到客户端的更新将按照发送的顺序接收,因为转换是在假设操作按顺序进行的情况下进行的。因此,传输机制需要保证在广播期间按顺序发送消息。
|
||||
|
||||
虽然延迟主要取决于网络连接速度和可靠性,但协议也起着作用。某些传输方法是持久性的,因此效率更高,因为不需要重复握手和初始化。可能的方法是:
|
||||
|
||||
* **长轮询**:在长轮询中,客户端向服务器发送请求,服务器保持连接打开状态,直到有新数据可用。一旦发送了数据,客户端会立即重新打开连接以等待下一次更新。
|
||||
* **WebSockets**:WebSockets 通过单个、长寿命的持久连接提供全双工通信通道。一旦在客户端和服务器之间建立了 WebSocket 连接,服务器就可以在更新发生时将更新推送到客户端。非常适合客户端和服务器都需要持续交换数据的场景,例如在聊天应用程序、多人游戏或协作编辑工具中。
|
||||
* **服务器发送事件 (SSE)**:SSE 是一种标准,允许服务器通过 HTTP 将基于文本的事件更新推送到客户端。与 WebSockets 不同,SSE 是单向的(服务器到客户端),更适合于仅服务器需要将数据推送到客户端的单向场景。对于客户端到服务器的通信,可以使用标准 HTTP 请求。SSE 非常适合更新不频繁或数据主要从服务器发送到客户端的应用程序。
|
||||
|
||||
#### 分析
|
||||
|
||||
**可靠性**:可靠性是这里最大的问题——我们如何确保从服务器发送的消息一定能被客户端收到?如果用户在移动或在咖啡馆,连接可能不稳定。持久连接可能会断开并需要重新连接。当客户端连接、重新连接或离线时,它可能会错过服务器上发生但无法到达客户端的消息。
|
||||
|
||||
这个问题在长轮询中尤其重要。虽然长轮询试图模拟实时更新,但在服务器上有新数据可用到客户端收到新数据之间可能存在延迟。这种延迟的发生是因为服务器需要等待现有的长轮询请求返回,然后客户端才能接收到新数据。重新连接实际上是内置于长轮询的工作方式中的,因此错失服务器更新的窗口更大。
|
||||
|
||||
**排序**:上述传输机制均不能保证消息的有序传递。由于客户端级别的消息排序,客户端到服务器的通信得到保证,方法是强制一次只有一个正在进行的请求。服务器到客户端的通信排序由通信协议要求,但未内置于其中。在 SSE 中,如果连接断开,它会自动重试,但其他方法则不然。
|
||||
|
||||
总的来说,**WebSockets 是协作编辑的最佳传输机制**,因为它具有低延迟和双向通信特性。WebSockets 被许多生产级应用程序广泛用于实时更新。虽然 WebSockets 不能保证消息排序,但可以通过应用程序层中的自定义逻辑(例如 [Socket.io](https://socket.io))来弥补这一差距。
|
||||
|
||||
也可以使用 SSE。我们可以使用 SSE 接收对等更新,并使用常规 HTTP 请求发送消息,但使用两种不同的传输机制进行协作编辑可能会令人困惑。
|
||||
|
||||
鉴于有更多克服其缺点的现代技术,长轮询不可行。
|
||||
|
||||
### 总体方法总结
|
||||
|
||||
让我们总结一下总体方法的主要方面。
|
||||
|
||||
* **高效的请求负载**:在更新期间仅发送和接收操作类型和必要信息,从而使请求变得小而高效。负载大小不受文档长度的影响。
|
||||
* **客户端-服务器网络架构**:选择客户端-服务器模型,参与者连接到中央服务器。拥有中央服务器简化了协作协议,使其更易于理解和实现,因为客户端只需专注于与服务器同步,而不是与其他客户端同步。服务器可以以相同的方式处理所有客户端。
|
||||
* **通过版本检测进行并发控制**:版本检测模型是最理想的,每个客户端都保存文档的副本,并且更改会尽快传播给对等方。文档永远不会被锁定,每个用户都可以在本地乐观地进行更改,而无需等待服务器确认。网络连接的速度和可靠性不会限制用户打字的速度。版本冲突可以通过转换操作 (OT) 来解决,或者使用特殊的数据结构 (CRDT) 来处理。
|
||||
* **通过 OT 进行冲突解决**:选择 OT 是因为它已经成熟,并且 OT 源于文档编辑。每个操作都提供了足够的信息来合并更新。多年来,Google Docs 也一直坚持使用 OT 技术,直到今天仍在继续使用 OT。
|
||||
* **将文档存储为修订日志**:文档存储为仅追加操作/更改的日志,这使得版本历史记录和高效计算客户端状态和服务器状态之间的差异成为可能。
|
||||
* **更新调度**:每个客户端维护一个“本地更新”缓冲区和“已发送更新”缓冲区,确保在任何时候都只有一个网络请求正在进行。
|
||||
* **操作粒度**:操作粒度是在意图级别决定的,并且连续的打字操作被合并。
|
||||
* **WebSockets 用于传输**:WebSockets 是低延迟和双向的,适用于服务器到客户端的通信。消息乱序问题可以通过应用层中的自定义逻辑来解决。
|
||||
|
||||
我们强烈建议查看这个[关于带有中央服务器的 OT 的交互式可视化](https://operational-transformation.github.io/),它确实有助于理解协作协议。
|
||||
|
||||
现在我们已经讨论了各种选项并确定了一种方法,我们可以使用 RADIO 框架部分重新组织和呈现它们。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
上面已经广泛地介绍了高级架构。总而言之,考虑到需求,客户端-服务器架构是最合适的:
|
||||
|
||||
* 中央服务器,所有客户端都与之通信。客户端之间不直接通信。
|
||||
* 服务器将真实来源保存为修订日志,这使得可以计算最新的文档状态。
|
||||
* 客户端尽快与服务器同步。
|
||||
|
||||

|
||||
|
||||
### 组件职责
|
||||
|
||||
我们将重点关注协作编辑器的关键组件。
|
||||
|
||||
* **UI**:显示文档并将事件发送到富文本编辑器核心。
|
||||
* **富文本编辑器核心**:保存文档状态/模型,类似于富文本编辑器系统设计的核心。通过操作底层文档状态来响应用户事件,从而触发 DOM 事件。提供用于外部修改文档状态的 API。
|
||||
* **同步引擎**:负责将本地更新同步到服务器、接收来自服务器的更新(转换适当的操作)并更新编辑器核心的模块。上面讨论的大部分内容都存在于此模块中。
|
||||
* **本地更新缓冲区**:尚未发送到服务器的本地操作。
|
||||
* **已发送更新缓冲区**:已发送到服务器但尚未得到服务器确认的本地操作。
|
||||
* **更新日志**:已作为文档历史记录一部分提交的修订。
|
||||
* **服务器**:可以接收更新并将其推送到客户端的 WebSocket 服务器。
|
||||
* **待处理更新**:尚未提交的来自客户端的操作。
|
||||
* **修订日志**:已处理的操作,可以提供文档的完整历史记录。
|
||||
|
||||
编辑器核心和 UI 与同步引擎和服务器后端完全解耦。编辑器核心为同步引擎提供了 API,以便根据接收到的操作修改文档模型。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
这里要讨论的核心状态是文档状态。根据[富文本编辑器系统设计文章](/questions/system-design/rich-text-editor),在富文本编辑器核心中,文档状态被建模为一棵树。
|
||||
|
||||
但是,在同步引擎中,文档存储为初始文档状态 + 多个操作列表(序列)(本地、已发送、已接收)。可以通过在初始文档状态之上应用这些操作来构造最新的文档状态。每个已提交的操作都会增加修订号。
|
||||
|
||||
| 修订号 | 用户 | 操作 | 时间戳 | 文档 |
|
||||
| ------- | ------- | ----------------- | ---------------- | -------------- |
|
||||
| 0 | N/A | N/A | 2024-08-03 10:00 | \<EMPTY> |
|
||||
| 1 | Charlie | `INS "Hello" @0` | 2024-08-03 10:05 | "Hello" |
|
||||
| 2 | Alice | `INS " world" @5` | 2024-08-03 10:10 | "Hello world" |
|
||||
| 3 | Bob | `INS "!" @11` | 2024-08-03 10:15 | "Hello world!" |
|
||||
|
||||
当新用户加入会话或当前用户刷新页面时,他们初始化的文档是最新版本,由服务器上所有已提交的操作构成;所有客户端操作列表都将为空。
|
||||
|
||||

|
||||
|
||||
上图演示了在不同时间加入的客户端的状态。当文档处于修订版 1 且仅包含“Hello”时,Alice 加入会话。她向文档添加了“ world”,并在她的屏幕上看到了“Hello world”。Alice 的文档由“Hello”的初始文档状态和她的插入操作“ world”构成。
|
||||
|
||||
Bob 然后在修订版 2 中加入会话,他直接使用“Hello world”的当前文档状态进行初始化,没有任何细粒度的操作历史记录。
|
||||
|
||||
### 文档级别的操作转换
|
||||
|
||||
到目前为止,我们在本文中提到的操作(和转换)都是在纯文本上下文中在句子级别执行的。但是文档是富文本,如何在文档上执行转换?
|
||||
|
||||
在[富文本编辑器系统设计文章](/questions/system-design/rich-text-editor)中,我们讨论了如何使用树数据结构来表示富文本文档,因此我们需要可以在树结构上使用的 OT。讨论 OT 的技术细节超出了本文的范围,但我们将简要讨论大致方法。
|
||||
|
||||
完整的富文本文档表示为一棵树,并且有两种类型的节点——元素节点和文本节点。元素节点可以包含子节点,即其他元素节点或文本节点,而文本节点是叶子,只能包含文本内容(纯文本)并具有格式标志。标题和段落是元素节点的子类,因为它们可以包含子文本节点。在高层次上,文档包含一个根节点,其中包含标题元素、段落元素列表,作为其直接子节点等。
|
||||
|
||||
操作转换在类似列表的字符串结构上运行良好。元素节点的子节点也位于列表结构中,看到这里的相似之处了吗?
|
||||
|
||||
让我们看看编辑文档时的一些潜在场景:
|
||||
|
||||
* **在同一句子中插入字符**:假设该句子包含在单个文本节点中。我们已经彻底地讨论了这种冲突。必须转换(增加偏移量)后部的插入,以便为前面的插入腾出空间。
|
||||
* **同时插入段落**:假设这些段落是文档根节点的直接子节点。插入段落等同于修改根节点的直接子节点,一个类似列表的结构。请注意,在同一句子中插入字符也会修改一个列表(字符列表),其中后部的插入操作需要移动。因此,可以使用类似的转换来解决段落插入的冲突。
|
||||
* **在不同的段落中插入字符**:这里没有冲突,因为修改了不同的节点。
|
||||
|
||||
这里的模式是,当修改相同的节点时会产生冲突,幸运的是,这在较长的文档中并不常见。 OT 技术可用于解决列表中的冲突,无论它们是字符列表(在句子和段落中)还是段落列表(在文档的根节点中)。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
我们将重点关注客户端和服务器之间同步所需的核心 API。
|
||||
|
||||
### 初始化 API
|
||||
|
||||
此 API 为客户端提供了启动协作编辑会话的必要信息。一个示例响应如下所示:
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": 145,
|
||||
"document": "..." // 富文本编辑器格式
|
||||
}
|
||||
```
|
||||
|
||||
### 更新/保存 API
|
||||
|
||||
此 API 允许将本地更新发送到服务器。由于这是使用 WebSockets 完成的,因此需要一个 `type` 字段来区分请求。请注意,单个更新请求中允许使用多个操作。
|
||||
|
||||
请求
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "UPDATE",
|
||||
"requestId": 2, // 每个客户端单调递增的整数
|
||||
"revision": 146, // 执行更新所依据的基本修订版
|
||||
"isUndo": false, // 区分新操作和撤消操作
|
||||
"operations": [
|
||||
{
|
||||
"type": "INSERT",
|
||||
"nodeId": 24, // 在文档上下文中需要
|
||||
"payload": {
|
||||
"characters": "Hello",
|
||||
"index": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "INSERT",
|
||||
"nodeId": 25,
|
||||
"payload": {
|
||||
"characters": "Bye",
|
||||
"index": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 更新确认回调
|
||||
|
||||
服务器在确认更新请求后将其发送给客户端。确认后,客户端可以将操作从“已发送更新”移动到“更新日志”。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ACK",
|
||||
"requestIdAcknowledged": 2,
|
||||
"requestId": 3,
|
||||
"revision": 147
|
||||
}
|
||||
```
|
||||
|
||||
### On update callback
|
||||
|
||||
这些是服务器发起的、表明对等方进行了更新的消息。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PEER_UPDATE",
|
||||
"revision": 148,
|
||||
"userId": 6543, // User who made the update
|
||||
"operations": [
|
||||
{
|
||||
"type": "INSERT",
|
||||
"nodeId": 24, // Needed in a document context to identify the node to modify
|
||||
"payload": {
|
||||
"characters": "Goodbye",
|
||||
"index": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "INSERT",
|
||||
"nodeId": 25,
|
||||
"payload": {
|
||||
"characters": " earth",
|
||||
"index": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### On reconnect callback
|
||||
|
||||
当客户端断开连接并最终重新连接时,服务器应向其发送客户端断开连接期间错过的任何修订。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SYNC",
|
||||
"revisions": [
|
||||
{
|
||||
"revision": 147,
|
||||
"userId": 6543, // User who made the update
|
||||
"operations": [
|
||||
{
|
||||
"type": "INSERT",
|
||||
"nodeId": 24, // Needed in a document context to identify the node to modify
|
||||
"payload": {
|
||||
"characters": "Goodbye",
|
||||
"index": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "INSERT",
|
||||
"nodeId": 25,
|
||||
"payload": {
|
||||
"characters": " earth",
|
||||
"index": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### 历史记录和版本控制
|
||||
|
||||
Google Docs 允许用户将文档历史记录作为版本列表查看。通过将文档存储为操作/更新的日志,我们可以“穿越时间”并返回到文档在任何时间点的状态。每个文档修订都由一个单调递增的正整数标识,该整数由该点之前的操作构成。
|
||||
|
||||
| 修订版号 | 用户 | 操作 | 时间戳 | 文档状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | N/A | N/A | 2024-08-03 10:00 | |
|
||||
| 1 | Charlie | `INS "Hello" @0` | 2024-08-03 10:05 | "Hello" |
|
||||
| 2 | Alice | `INS " world" @5` | 2024-08-03 10:10 | "Hello world" |
|
||||
| 3 | Bob | `INS "!" @11` | 2024-08-03 10:15 | "Hello world!" |
|
||||
| 4 | Donald | `INS " Goodbye" @12` | 2024-08-05 09:00 | "Hello world! Goodbye" |
|
||||
| 5 | Erin | `INS " earth" @20` | 2024-08-05 09:10 | "Hello world! Goodbye earth" |
|
||||
|
||||
尽管每个细微的变化都存储在数据库中,但显示一个版本日志更有意义,该日志将多个修订版组合在一起。在短时间内一起进行的更改是同一版本的一部分:
|
||||
|
||||
| 版本 | 时间 | 最后编辑者 |
|
||||
| ------- | ---------------- | ------------------- |
|
||||
| 1 | 2024-08-03 10:15 | Charlie, Bob, Alice |
|
||||
| 2 | 2024-08-05 09:10 | Donald, Erin |
|
||||
|
||||
这与您单击“版本历史记录”按钮时 Google Docs 显示的内容类似。
|
||||
|
||||
### 撤消/重做
|
||||
|
||||
对于富文本编辑器来说,撤消/重做是一个棘手的话题,对于协作编辑器来说更是如此。
|
||||
|
||||
用户的撤消/重做历史记录应该在用户级别还是文档级别(所有参与者共享相同的撤消/重做)? 仅撤消您自己的操作更有意义,因为用户不太可能知道其他人正在做什么,并且希望撤消它们。
|
||||
|
||||
该文档的修订日志是仅附加的;我们只能添加,不能删除。与此同时,删除已经提交的操作很复杂,因为它可能需要其他客户端撤消某些转换。Google Docs 通过将前一个操作的否定作为新更新来实施撤消。客户端可以过滤其“更新日志”以查找由他们所做的更改,并附加这些操作的否定。
|
||||
|
||||
因此,更新操作需要一个 `isUndo` 标志来区分新操作与撤消操作,并在识别其最后一个非撤消操作时将它们过滤掉,否则用户将陷入撤消/重做(撤消撤消)最近的操作。
|
||||
|
||||
| 修订版号 | 用户 | 操作 | 是否撤消 | 文档 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 4 | Donald | `INS " Goodbye" @12` | False | "Hello world! Goodbye" |
|
||||
| 5 | Erin | `INS " earth" @20` | False | "Hello world! Goodbye earth" |
|
||||
| 6 | Erin | `INS "..." @26` | False | "Hello world! Goodbye earth…" |
|
||||
| 7 | Erin | `DEL 3 @29` | True | "Hello world! Goodbye earth" |
|
||||
| 8 | Erin | `DEL 6 @26` | True | "Hello world! Goodbye" |
|
||||
|
||||
上面的修订日志演示了 Erin 的插入如何作为删除操作(插入的否定)附加到修订日志中,作为修订版 7 和 8。
|
||||
|
||||
### 可靠性
|
||||
|
||||
可靠性是确保从服务器发送的消息保证按发送顺序被客户端接收。客户端可以随时断开连接,这可能导致服务器发送消息而客户端未收到。客户端也可能处于不稳定的网络连接上,并且某些消息会在传输过程中丢失。
|
||||
|
||||
如果消息包含整个文档状态,则丢失的消息不是问题,但如上所述,这效率不高。在我们的方法中,消息包含关键的细粒度更新,并且错过其中任何一个都将导致文档副本不同步。客户端必须接收所有对等操作才能收敛到一致的文档状态。
|
||||
|
||||
这就是文档修订号派上用场的地方,如果服务器知道客户端拥有的文档修订版,它可以使用该信息来计算客户端缺少哪些更新。
|
||||
|
||||
谁应该跟踪每个客户端的最新文档修订号?让服务器跟踪每个客户端的文档修订号既麻烦又不可扩展。一种可扩展的方法涉及客户端维护他们拥有的最新文档修订号,并将其包含在服务器请求中。这样,服务器可以保持相对无状态。
|
||||
|
||||
如果客户端使用修订版 5 重新连接到会话,而服务器当前位于修订版 8,则服务器知道客户端缺少修订版 6、7 和 8,因此可以发送修订版 6、7 和 8 的更新。
|
||||
|
||||
其他可靠性要求包括按顺序传递、重试和确认。WebSocket 不包含这些功能,因此必须通过 [Socket.io](https://socket.io/) 等库将自定义逻辑添加到应用层。
|
||||
|
||||
上面概述的协作协议可以很好地处理不稳定的连接。只要客户端保留文档修订号并将其作为请求负载的一部分发送到服务器,服务器就能够确定客户端缺少哪些更新。每个更新操作也都有一个唯一的 ID 标记,这有助于在重复请求的情况下进行去重。
|
||||
|
||||
### 离线编辑
|
||||
|
||||
截至撰写本文时(2024 年 8 月),Google Docs 不支持离线编辑。但是,使用当前的架构可以相对轻松地支持离线编辑。当客户端检测到没有网络连接时,用户可以继续编辑,但任何更新都保留在“本地更新”缓冲区中,并且不会发送出去。
|
||||
|
||||
当客户端重新获得网络连接时:
|
||||
|
||||
* **将本地更新发送到服务器**:“本地更新”被发送到服务器并移动到“发送更新”缓冲区。
|
||||
* **从服务器获取更新**:客户端可能在离线时错过了某些更新,服务器应该推送自客户端上次同步修订以来的缺失更新。
|
||||
|
||||
### 文档格式
|
||||
|
||||
Google Docs 和 Microsoft Word 等文字处理器支持多种文件格式。其中,`.docx` (Microsoft Word) 和 `.odt` (OpenDocument Text) 文件格式最受欢迎。
|
||||
|
||||
`.docx` 和 `.odt` 文件格式的规范是公开提供的:
|
||||
|
||||
* `.docx`:[Office Open XML (.docx) 文件的 Word 扩展](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-docx/b839fe1f-e1ca-4fa6-8c26-5954d0abbccd)
|
||||
* `.odt`:[OpenDocument 技术规范](https://en.wikipedia.org/wiki/OpenDocument_technical_specification)
|
||||
|
||||
虽然 Google Docs 可以打开这些文件格式,但这并不意味着内部文档状态与它们完全匹配。只要文字处理器包含在外部格式与其内部状态之间进行导入和导出的模块,该软件就可以在内部使用任何格式。
|
||||
|
||||
让我们简要地看一下 `.odt` 文件中包含的内容。`.odt` 文件是 OpenDocument Text 文件,这是一种主要由 LibreOffice Writer 和 Apache OpenOffice 等文字处理应用程序使用的格式。它们类似于 Microsoft Word 使用的 `.docx` 文件,但基于 OpenDocument 格式,这是一种用于文档文件类型的开放标准。
|
||||
|
||||
`.odt` 文件本质上是一个 ZIP 存档,其中包含几个 XML 文件和目录,每个文件和目录都用于存储文档的内容、样式、设置和其他方面。以下是 `.odt` 文件中主要 XML 文件的示例:
|
||||
|
||||
**`content.xml`**: 这是包含文档实际文本内容及其结构的核心文件。它包括段落、表格、列表和其他文档元素,采用 XML 格式。
|
||||
|
||||
```xml
|
||||
<office:text>
|
||||
<text:p>这是文档中的一个文本段落。</text:p>
|
||||
<text:table>
|
||||
<!-- 表格数据在这里 -->
|
||||
</text:table>
|
||||
</office:text>
|
||||
```
|
||||
|
||||
**`styles.xml`**: 此文件定义了文档中使用的样式,例如段落样式、字符样式、表格样式和页面布局。它确保了文档的格式一致性。
|
||||
|
||||
```xml
|
||||
<office:styles>
|
||||
<style:style style:name="Heading1" style:family="paragraph">
|
||||
<style:text-properties fo:font-size="18pt" fo:font-weight="bold"/>
|
||||
</style:style>
|
||||
</office:styles>
|
||||
```
|
||||
|
||||
**`meta.xml`**: 包含有关文档的元数据,例如标题、作者、创建和修改日期、字数以及其他描述性信息。
|
||||
|
||||
```xml
|
||||
<office:meta>
|
||||
<meta:initial-creator>John Doe</meta:initial-creator>
|
||||
<dc:title>我的文档</dc:title>
|
||||
<meta:creation-date>2024-08-14T10:00:00</meta:creation-date>
|
||||
</office:meta>
|
||||
```
|
||||
|
||||
**`settings.xml`**: 存储与文档相关的各种设置,例如页面视图选项、打印机设置以及影响文档显示或打印方式的其他用户首选项。
|
||||
|
||||
```xml
|
||||
<office:settings>
|
||||
<config:config-item-set config:name="ooow:ViewSettings">
|
||||
<config:config-item config:name="ViewMode" config:type="string">Normal</config:config-item>
|
||||
</config:config-item-set>
|
||||
</office:settings>
|
||||
```
|
||||
|
||||
**`manifest.xml`**: 此文件列出了 `.odt` 存档中包含的所有文件及其 MIME 类型。它充当存档内容的目录。
|
||||
|
||||
```xml
|
||||
<manifest:manifest>
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>
|
||||
```
|
||||
|
||||
**其他文件**:
|
||||
|
||||
* **图像**:如果文档包含图像,则它们将作为单独的文件存储在包中。
|
||||
* **嵌入对象**:其他类型的嵌入对象,例如图表或电子表格,也可能作为单独的文件包含在内。
|
||||
|
||||
这些 XML 文件协同工作,定义文档的内容、外观、元数据和设置,使其能够在支持 OpenDocument 格式的不同文字处理软件中一致地打开、编辑和显示。
|
||||
|
||||
***
|
||||
|
||||
## 参考资料
|
||||
|
||||
* [群件系统中的并发控制](https://dl.acm.org/doi/pdf/10.1145/67544.66963)
|
||||
* [Jupiter 协作系统中的高延迟、低带宽窗口](https://dl.acm.org/doi/pdf/10.1145/215585.215706)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "d5b970d4",
|
||||
"excerpt": "b84462b7"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: Google Sheets
|
||||
excerpt: 设计一个类似 Google Sheet 和 Excel 的协作电子表格
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
待办事项
|
||||
|
||||
### 真实案例
|
||||
|
||||
* 待办事项
|
||||
* 待办事项
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"96d7ce5e",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"c4dfcbe1",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"fb56b079",
|
||||
"6188f3b0",
|
||||
"55e0729b",
|
||||
"6188f3b0",
|
||||
"74533939",
|
||||
"6188f3b0",
|
||||
"64d55982",
|
||||
"c9386485",
|
||||
"89fbc5d8",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"6188f3b0"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"96d7ce5e",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"c4dfcbe1",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"fb56b079",
|
||||
"6188f3b0",
|
||||
"55e0729b",
|
||||
"6188f3b0",
|
||||
"74533939",
|
||||
"6188f3b0",
|
||||
"64d55982",
|
||||
"c9386485",
|
||||
"89fbc5d8",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"6188f3b0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
## 需求探索
|
||||
|
||||
TODO
|
||||
|
||||
* 编辑单元格值
|
||||
* 单元格格式
|
||||
* 公式
|
||||
|
||||
***
|
||||
|
||||
## 架构/高层设计
|
||||
|
||||
TODO
|
||||
|
||||
* App Root
|
||||
* Table
|
||||
* Toolbar
|
||||
* 公式行
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
TODO
|
||||
|
||||
### 渲染:DOM vs Canvas
|
||||
|
||||
TODO
|
||||
|
||||
### 表格虚拟化
|
||||
|
||||
TODO
|
||||
|
||||
### 公式解析
|
||||
|
||||
TODO
|
||||
|
||||
* 拓扑排序以检测循环和依赖关系。
|
||||
* 递归地解析依赖关系和值。
|
||||
|
||||
### 格式化
|
||||
|
||||
* 行/列级格式。
|
||||
* 单元格级格式覆盖。
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
TODO
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "f10212aa",
|
||||
"excerpt": "a67e3ebb"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: 图表工具(例如 Lucidchart)
|
||||
excerpt: 设计一个类似 Lucidchart 的设计工具
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
待办事项
|
||||
|
||||
### 真实案例
|
||||
|
||||
* 待办事项
|
||||
* 待办事项
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"91d9b33b",
|
||||
"8525af45",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"b77e6dc1",
|
||||
"746d005f",
|
||||
"8b6045de",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"90b4dca2",
|
||||
"9dc8ec5",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"7f882997",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"a9ffc8e8"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"91d9b33b",
|
||||
"8525af45",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"b77e6dc1",
|
||||
"746d005f",
|
||||
"8b6045de",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"90b4dca2",
|
||||
"9dc8ec5",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"7f882997",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"a9ffc8e8"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
## 需求探索
|
||||
|
||||
待办事项
|
||||
|
||||
***
|
||||
|
||||
## 架构/高层设计
|
||||
|
||||
待办事项
|
||||
|
||||
React是有效的:将数据与渲染分离,再进行同步。可以独立构建这些部分。
|
||||
|
||||
* UI
|
||||
* 场景
|
||||
* 元素
|
||||
* 工具栏
|
||||
* 属性面板
|
||||
* 动作管理器
|
||||
* 历史记录
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
待办事项
|
||||
|
||||
* App 状态:https://github.com/excalidraw/excalidraw/blob/e6de1fe4a41947a7980c416e353f1e66ec5fc50b/src/types.ts
|
||||
|
||||
* 场景
|
||||
* 元素
|
||||
|
||||
* 历史记录
|
||||
|
||||
* 选定元素
|
||||
|
||||
```js
|
||||
// 对象的Data model。
|
||||
{
|
||||
type: 'rectangle',
|
||||
x: 404,
|
||||
y: 237,
|
||||
width: 200,
|
||||
height: 100,
|
||||
angle: 0,
|
||||
backgroundColor: 'red',
|
||||
strokeColor: '#000',
|
||||
strokeWidth: 2,
|
||||
strokeStyle: 'solid',
|
||||
opacity: 1,
|
||||
}
|
||||
```
|
||||
|
||||
* 如何拥有父/子元素。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
待办事项
|
||||
|
||||
动作负载:
|
||||
|
||||
* https://github.com/excalidraw/excalidraw/blob/d4afd6626850befdb000d86c203e7a604f8a871c/src/actions/types.ts
|
||||
|
||||
* https://github.com/excalidraw/excalidraw/blob/6c1246ef77433fcd178be380b451c5b904aece00/src/actions/index.ts
|
||||
|
||||
* 每个动作都有一个名称、负载、逻辑(reducer fn)、谓词、提交到历史记录和键盘快捷键。
|
||||
|
||||
* 动作管理器
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
待办事项
|
||||
|
||||
* 渲染
|
||||
* 模型:Canvas 或 SVG。
|
||||
* Canvas:Excalidraw/Figma
|
||||
* SVG:diagrams.net
|
||||
* 基本属性(笔画/背景/边缘/不透明度)
|
||||
* 转换
|
||||
* 缩放
|
||||
* 旋转
|
||||
* 缩放
|
||||
* 图层
|
||||
* 操作
|
||||
* 选择
|
||||
* https://github.com/excalidraw/excalidraw/blob/327ed0e2d1c8f79fba7a46bcd6e8ee0404a2d0de/src/scene/selection.ts
|
||||
* 删除
|
||||
* 转换
|
||||
* 对齐
|
||||
* https://github.com/excalidraw/excalidraw/blob/d2cc76e52eae9377f924172efebb81fae8baba5a/src/distribute.ts
|
||||
* 前/后排序
|
||||
* 分组
|
||||
* https://github.com/excalidraw/excalidraw/blob/6d0716eb6bd23685fc92277ae86dd3ea5745270f/src/groups.ts
|
||||
* 复制/粘贴/重复
|
||||
* 历史记录:撤消/重做
|
||||
* https://github.com/excalidraw/excalidraw/blob/master/src/history.ts
|
||||
* 并非所有操作都应提交到历史记录(例如保存)
|
||||
* 性能
|
||||
* 缓存画布元素
|
||||
* 仅重新渲染可见屏幕
|
||||
* 键盘快捷键
|
||||
* 实时协作
|
||||
* Socket.io / 房间
|
||||
* 同步
|
||||
* 添加
|
||||
* 编辑
|
||||
* 版本控制
|
||||
* 删除
|
||||
* `isDeleted` 标志
|
||||
* 锁定正在编辑的元素
|
||||
* https://blog.excalidraw.com/building-excalidraw-p2p-collaboration-feature/
|
||||
* 导出
|
||||
* SVG/图像/保存到磁盘
|
||||
* 读/写文件系统
|
||||
* 将数据模型转换为 JSON
|
||||
* https://blog.excalidraw.com/browser-fs-access/
|
||||
* https://github.com/excalidraw/excalidraw/blob/bbe0c35f66a3fded6a2d8e61974d19f0f3b0e803/src/scene/export.ts
|
||||
* 共享
|
||||
* 在 URL 中编码为 base64
|
||||
* 端到端加密
|
||||
* https://blog.excalidraw.com/end-to-end-encryption/
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
* [弃用 Excalidraw Electron,转而使用 Web 版本](https://web.dev/deprecating-excalidraw-electron/)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "99ee903c",
|
||||
"excerpt": "70e78182"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"5b613d09",
|
||||
"673f3b83",
|
||||
"9ec6d64c",
|
||||
"4756ebe7"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"5b613d09",
|
||||
"673f3b83",
|
||||
"9ec6d64c",
|
||||
"4756ebe7"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
title: 下拉菜单
|
||||
excerpt: 设计一个下拉菜单组件,该组件显示包含操作列表的菜单
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个下拉菜单组件,该组件可以显示包含操作列表的菜单。
|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* [下拉菜单 · Bootstrap v5.3](https://getbootstrap.com/docs/5.3/components/dropdowns)
|
||||
* [React 菜单组件 - Material UI](https://mui.com/material-ui/react-menu/)
|
||||
* [下拉菜单 — Radix UI](https://www.radix-ui.com/docs/primitives/components/dropdown-menu)
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"7a50ad3f",
|
||||
"50ca61a9",
|
||||
"760b5187",
|
||||
"d9b66a6c",
|
||||
"ccd790ed",
|
||||
"62d95a89",
|
||||
"c4e8b38",
|
||||
"82065f1f",
|
||||
"24febbf2",
|
||||
"f7fe2ea",
|
||||
"2a7816d0",
|
||||
"1c24b706",
|
||||
"3cce7975",
|
||||
"cf70a2fd",
|
||||
"104d07c9",
|
||||
"e0ff5dd5",
|
||||
"2580357",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"8373fafa",
|
||||
"6ec06c34",
|
||||
"37ed5879",
|
||||
"743988f5",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"d73d335",
|
||||
"36f975b",
|
||||
"b982cc5b",
|
||||
"7595e227",
|
||||
"55ee066c",
|
||||
"93be96c3",
|
||||
"3d0e4d6d",
|
||||
"d6531ea4",
|
||||
"52678e2f",
|
||||
"d1ad68ff",
|
||||
"1c848bda",
|
||||
"bf870d80",
|
||||
"187fec08",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"65aca1ee",
|
||||
"9ed36d4c",
|
||||
"bec77274",
|
||||
"ad91487e",
|
||||
"b8f49225",
|
||||
"26f439e7",
|
||||
"4117fe4a",
|
||||
"6cdf121f",
|
||||
"f533e100",
|
||||
"291d8871",
|
||||
"9707b6ec",
|
||||
"100177d9",
|
||||
"a5940b33",
|
||||
"4e94cf49",
|
||||
"7aa49345",
|
||||
"a3c8f21d",
|
||||
"8b38ebc8",
|
||||
"90ab26cf",
|
||||
"ad8a3821",
|
||||
"7bed610a",
|
||||
"ac71feb1",
|
||||
"707426",
|
||||
"731edb71",
|
||||
"6ad88c74",
|
||||
"668bf9c8",
|
||||
"ed48f415",
|
||||
"79c9d5a4",
|
||||
"ea0373ae",
|
||||
"bcecc3a",
|
||||
"f464316c",
|
||||
"49e04b8e",
|
||||
"4e7baae8",
|
||||
"1c4c4734",
|
||||
"e7bb7378",
|
||||
"371fea80",
|
||||
"63717257",
|
||||
"effb6d3f",
|
||||
"78113b6c",
|
||||
"40a494fa",
|
||||
"6eb6471",
|
||||
"aae3ce43",
|
||||
"5e6b920a",
|
||||
"ca0ad6ad",
|
||||
"7f022d8e",
|
||||
"147fca85",
|
||||
"f06ee28",
|
||||
"c13f1013",
|
||||
"2cde4651",
|
||||
"2eeb5e3e",
|
||||
"6a2eb7a7",
|
||||
"69666226",
|
||||
"4a5d5de0",
|
||||
"a98f5492",
|
||||
"df29b84a",
|
||||
"ba4c8768",
|
||||
"66abbb46",
|
||||
"6494ed05",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"d1716209"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"7a50ad3f",
|
||||
"50ca61a9",
|
||||
"760b5187",
|
||||
"d9b66a6c",
|
||||
"ccd790ed",
|
||||
"62d95a89",
|
||||
"c4e8b38",
|
||||
"82065f1f",
|
||||
"24febbf2",
|
||||
"f7fe2ea",
|
||||
"2a7816d0",
|
||||
"1c24b706",
|
||||
"3cce7975",
|
||||
"cf70a2fd",
|
||||
"104d07c9",
|
||||
"e0ff5dd5",
|
||||
"2580357",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"8373fafa",
|
||||
"6ec06c34",
|
||||
"37ed5879",
|
||||
"743988f5",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"d73d335",
|
||||
"36f975b",
|
||||
"b982cc5b",
|
||||
"7595e227",
|
||||
"55ee066c",
|
||||
"93be96c3",
|
||||
"3d0e4d6d",
|
||||
"d6531ea4",
|
||||
"52678e2f",
|
||||
"d1ad68ff",
|
||||
"1c848bda",
|
||||
"bf870d80",
|
||||
"187fec08",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"65aca1ee",
|
||||
"9ed36d4c",
|
||||
"bec77274",
|
||||
"ad91487e",
|
||||
"b8f49225",
|
||||
"26f439e7",
|
||||
"4117fe4a",
|
||||
"6cdf121f",
|
||||
"f533e100",
|
||||
"291d8871",
|
||||
"9707b6ec",
|
||||
"100177d9",
|
||||
"a5940b33",
|
||||
"4e94cf49",
|
||||
"7aa49345",
|
||||
"a3c8f21d",
|
||||
"8b38ebc8",
|
||||
"90ab26cf",
|
||||
"ad8a3821",
|
||||
"7bed610a",
|
||||
"ac71feb1",
|
||||
"707426",
|
||||
"731edb71",
|
||||
"6ad88c74",
|
||||
"668bf9c8",
|
||||
"ed48f415",
|
||||
"79c9d5a4",
|
||||
"ea0373ae",
|
||||
"bcecc3a",
|
||||
"f464316c",
|
||||
"49e04b8e",
|
||||
"4e7baae8",
|
||||
"1c4c4734",
|
||||
"e7bb7378",
|
||||
"371fea80",
|
||||
"63717257",
|
||||
"effb6d3f",
|
||||
"78113b6c",
|
||||
"40a494fa",
|
||||
"6eb6471",
|
||||
"aae3ce43",
|
||||
"5e6b920a",
|
||||
"ca0ad6ad",
|
||||
"7f022d8e",
|
||||
"147fca85",
|
||||
"f06ee28",
|
||||
"c13f1013",
|
||||
"2cde4651",
|
||||
"2eeb5e3e",
|
||||
"6a2eb7a7",
|
||||
"69666226",
|
||||
"4a5d5de0",
|
||||
"a98f5492",
|
||||
"df29b84a",
|
||||
"ba4c8768",
|
||||
"66abbb46",
|
||||
"6494ed05",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"d1716209"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,351 @@
|
|||
## 需求探索
|
||||
|
||||
### 可以同时打开多个下拉菜单吗?
|
||||
|
||||
是的,可以。
|
||||
|
||||
### 菜单将包含什么内容?
|
||||
|
||||
仅文本,没有可聚焦的元素。
|
||||
|
||||
### 菜单中允许的最大项目数是多少?
|
||||
|
||||
没有固定的最大值,但最好不要超过 20 个项目,以获得更好的用户体验。
|
||||
|
||||
### 用户在自定义设计方面有多大的灵活性?
|
||||
|
||||
用户应该能够自定义颜色、字体、填充等,以匹配他们的品牌。
|
||||
|
||||
### 此组件将在哪些设备上使用?
|
||||
|
||||
所有设备 — 手机、平板电脑、桌面。
|
||||
|
||||
***
|
||||
|
||||
{/* ## TODO: Background, discuss when dropdown menus should be used. */}
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
在 React 中使用下拉菜单的示例,省略了事件处理程序。
|
||||
|
||||
```jsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Button>Actions</DropdownMenu.Button>
|
||||
<DropdownMenu.List>
|
||||
<DropdownMenu.Item>New File</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>Save</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>Delete</DropdownMenu.Item>
|
||||
</DropdownMenu.List>
|
||||
</DropdownMenu>
|
||||
```
|
||||
|
||||
| Component | Role |
|
||||
| --- | --- |
|
||||
| Dropdown Menu (`DropdownMenu`) | 根组件,协调内部组件之间的事件。 |
|
||||
| Menu Button (`DropdownMenu.Button`) | 切换`DropdownMenu.List`显示状态的按钮。 |
|
||||
| Menu List (`DropdownMenu.List`) | 包含项目列表。 |
|
||||
| Menu List Item (`DropdownMenu.Item`) | 单个列表项。 |
|
||||
|
||||
在 React 中,组件可以使用 context 或 props 与其父组件通信。我们选择在这里使用 context,因为我们在这里使用组合模型,传递 props 并不方便。`<DropdownMenu>` 应该包含一个 context provider,它向其所有子组件提供状态值(参见数据模型部分)。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
请注意,对于设计组件,先设计接口/API或同时设计数据模型和API可能是有意义的。这取决于手头的组件。随意在两个部分之间跳转。
|
||||
|
||||
| 状态 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `isOpen` | `boolean` | 菜单当前是打开还是关闭。 |
|
||||
| `activeItem` | `string` | 获得焦点的菜单项。我们需要这个的原因是悬停在菜单项上会更改活动项。通过在状态中跟踪此值,我们可以响应键盘交互,要么聚焦于上一个/下一个项目,要么激活它。
|
||||
|
||||
这些状态值位于`<DropdownMenu>`中,并通过React上下文提供给所有组件。
|
||||
|
||||
有关配置选项,请参见下文,这些选项也是数据模型的一部分。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
### 一般属性
|
||||
|
||||
这些属性适用于大多数组件。
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `children` | `React.Node` | 组件的子项。如果使用TypeScript/Flow,您可以强制使用特定的组件作为`children`。 |
|
||||
| `as` | `string \| Component` | 如果需要自定义底层DOM元素/组件。 |
|
||||
| `className` | `string` | 要添加到组件的类名,以防需要进一步的视觉定制。可能需要也可能不需要,具体取决于主题方法。
|
||||
|
||||
### `DropdownMenu`
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `isInitiallyOpen` | `boolean` | 菜单最初是打开还是关闭。 |
|
||||
| `size` | `string` | 用于自定义大小的属性。仅在需要自定义时才需要。 |
|
||||
|
||||
### `DropdownMenu.Button`
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `onClick` | `function` | 尽管打开/关闭将在`DropdownMenu`中处理,但如果需要执行其他逻辑(例如分析日志记录),则公开`onClick`属性很有用 |
|
||||
| 其他`button`属性 | \* | 由于此组件通常是`<button>`,因此它也应允许`<button>`期望的其他属性,例如`disabled`
|
||||
|
||||
### `DropdownMenu.List`
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `maxHeight` | `number \| undefined` | 菜单列表的最大高度。应该有一个大约200-300px的合理默认值。 |
|
||||
| `position` | `string` | 列表相对于按钮的位置。
|
||||
|
||||
### `DropdownMenu.Item`
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `onClick` | `function` | 激活项目时触发。可能的响应包括导航到另一个页面。 |
|
||||
| `disabled` | `boolean` | 项目是否已禁用。禁用项目无法激活,并且在使用键盘与菜单交互时可以选择跳过。 |
|
||||
|
||||
### 自定义外观
|
||||
|
||||
用于自定义UI组件的良好API设计可以在[前端面试指南的UI组件API设计原则部分](/front-end-interview-guidebook/user-interface-components-api-design-principles)中找到。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入探讨
|
||||
|
||||
### 渲染
|
||||
|
||||
由于菜单是“浮动”的,并且没有完全遵循页面元素的正常流程,因此渲染下拉菜单可能非常棘手。
|
||||
|
||||
#### 布局
|
||||
|
||||
有两种常见的方法可以在按钮附近渲染下拉菜单。我们为每种布局方法提供了最少的代码示例。
|
||||
|
||||
**相对于按钮**
|
||||
|
||||
在这种方法中,我们用 `position: relative` 包裹一个 `<div>`,围绕 `<button>` 和菜单。菜单使用 `position: absolute`,这使其相对于其最近的定位祖先定位,即根 `<div>`。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/dropdown-menu-relative-button-emxn9u?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="相对于按钮触发器的下拉菜单"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
此方法很简单,不需要太多计算元素及其在页面上的位置,但是具有 `overflow: hidden` 的父容器可能会剪切菜单及其内容,或者可能存在 `z-index` 问题。
|
||||
|
||||
这种方法被 [Headless UI](https://headlessui.com/react/menu) 和 [Bootstrap](https://getbootstrap.com/docs/5.3/components/dropdowns/) 使用。
|
||||
|
||||
**相对于页面**
|
||||
|
||||
在这种方法中,菜单被渲染为 `<body>` 的直接子元素,并通过获取元素的 `offsetTop` 和 `offsetLeft` 相对于**页面**进行 `absolute` 定位,以获取 `<button>` 相对于页面的坐标,并添加其高度 (`offsetHeight`) 以获取渲染菜单的最终 Y 位置。
|
||||
|
||||
在 React 中,这可以使用 [React Portals](https://beta.reactjs.org/reference/react-dom/createPortal) 完成,它允许您在父组件的 DOM 层次结构之外进行渲染。Portals 的一个典型用例是当父组件具有 `overflow: hidden` 或 `z-index` 样式,但您需要子组件在视觉上“跳出”其容器时。常见的例子包括下拉菜单、工具提示、模态框。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/dropdown-menu-relative-page-r4zoiu
|
||||
?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="使用 React Portal 实现的相对于页面的下拉菜单"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
这种方法的缺点是,如果窗口大小调整或页面内容发生变化,导致页面高度短于菜单首次显示时,菜单的位置将不正确。作为一种解决方法,您可以监视窗口的高度变化,并使用正确的位置重新渲染菜单。
|
||||
|
||||
这种方法被 [Radix UI](https://www.radix-ui.com/docs/primitives/components/dropdown-menu) 和 [Reach UI](https://reach.tech/menu-button) 使用。
|
||||
|
||||
#### 位置
|
||||
|
||||
该组件还允许自定义对齐方式,在 `<button>` 周围的所有方向上。在下面的示例中可以找到左/右对齐的菜单列表的示例。支持更多对齐方式取决于找到 `top`/`left`/`right`/`bottom`/CSS 转换的正确值。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/dropdown-menu-relative-button-emxn9u?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="下拉菜单位置"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
#### 最大高度
|
||||
|
||||
由于菜单中没有最大允许的项目数,我们可以为菜单设置一个默认的最大高度,以便可以通过在菜单内滚动来访问多余的项目。此高度也可以自定义。
|
||||
|
||||
#### 在 HTML 或 JavaScript 中渲染
|
||||
|
||||
下拉菜单可以是:
|
||||
|
||||
1. 像[Bootstrap 的下拉菜单](https://getbootstrap.com/docs/5.3/components/dropdowns/)一样渲染到 HTML 中。菜单最初通过`display: none` / `opacity: 0` / `hidden`属性从视图中隐藏,当要显示菜单时,这些样式会被切换。
|
||||
2. 在激活菜单按钮后,通过 JavaScript 动态渲染。
|
||||
|
||||
首先在 HTML 中渲染的优点是,由于显示菜单所需的 DOM 操作较少,因此运行时性能更好。缺点是 HTML 可能会不必要地膨胀,特别是如果用户根本不与下拉菜单交互。由于菜单项通常不会对 SEO 做出贡献,并且可能不会有那么多元素,因此预先在 HTML 中渲染菜单的好处相对较小。
|
||||
|
||||
### 靠近边缘时自动翻转
|
||||
|
||||
智能下拉菜单将知道其相对于视口的位置,并且当没有足够的空间在其当前位置显示完整菜单时,可以自行翻转。[Reach UI 的菜单](https://reach.tech/menu-button) 实现了自动翻转。
|
||||
|
||||
可选地,当菜单打开时,可以在窗口上禁用滚动(通过向`<body>`添加`overflow: hidden`)。这限制了用户体验,但避免了对自动翻转的需求,而自动翻转的实现可能很复杂。[Material UI 的菜单](https://mui.com/material-ui/react-menu)组件采用了这种方法。
|
||||
|
||||

|
||||
|
||||
这里的核心思想是了解菜单的高度,并在菜单底部超出视口高度时自动翻转。
|
||||
|
||||
* **视口高度**:`window.innerHeight`。
|
||||
* **菜单底边的位置**:这是以下各项的组合:
|
||||
1. 按钮相对于视口的位置 (`buttonEl.getBoundingClientRect().y`)
|
||||
2. 按钮高度 (`buttonEl.getBoundingClientRect().height`)
|
||||
3. 菜单高度 (`menuEl.getBoundingClientRect().height`)
|
||||
4. 按钮和菜单之间的间距
|
||||
|
||||
这是一个关于如何在 React 中实现菜单自动翻转行为的示例。确保使预览高度足够短,以便在操作中看到自动翻转。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/dropdown-menu-autoflip-ybqbeu?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Dropdown Menu Autoflipping"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
{/* ### TODO `z-index` */}
|
||||
|
||||
### 可访问性 (a11y)
|
||||
|
||||
#### 鼠标交互
|
||||
|
||||
单击按钮会切换菜单的显示状态。单击打开的菜单外部将关闭该菜单。我们必须确保菜单内的单击不会关闭它。
|
||||
|
||||
基于[React hook `useOnClickOutside`](https://usehooks.com/useOnClickOutside/),用于实现点击外部行为的伪代码:
|
||||
|
||||
```js
|
||||
function clickListener(event) {
|
||||
// 如果点击的元素是按钮或
|
||||
// 菜单的后代,则不执行任何操作。
|
||||
if (
|
||||
$buttonElement.contains(event.target) ||
|
||||
$menuElement.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', clickListener);
|
||||
document.addEventListener('touchstart', clickListener);
|
||||
```
|
||||
|
||||
请记住在菜单关闭时删除`clickListener`。
|
||||
|
||||
以下是如何在 React 中实现点击外部以关闭行为的方法:
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/dropdown-menu-click-outside-lq040x?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="dropdown-menu-click-outside-lq040x"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
#### 焦点管理
|
||||
|
||||
当菜单打开时,焦点被捕获,按下<kbd>Tab</kbd>不应将焦点转移到另一个元素上。当菜单关闭时,焦点返回到按钮。
|
||||
|
||||
#### 键盘交互
|
||||
|
||||
需要遵循两种 WAI-ARIA 模式:[菜单按钮模式](https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/)和[菜单模式](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)。后者在菜单打开后需要。
|
||||
|
||||
**按钮**
|
||||
|
||||
当焦点在按钮上时:
|
||||
|
||||
| 键 | 描述 |
|
||||
| --- | --- |
|
||||
| <kbd>Enter</kbd> | 打开菜单并将焦点置于第一个菜单项。 |
|
||||
| <kbd>Space</kbd> | 打开菜单并将焦点置于第一个菜单项。 |
|
||||
| <kbd>ArrowDown</kbd> (可选) | 打开菜单并将焦点移动到第一个菜单项。 |
|
||||
| <kbd>ArrowUp</kbd> (可选) | 打开菜单并将焦点移动到最后一个菜单项。 |
|
||||
|
||||
**菜单**
|
||||
|
||||
当焦点在菜单项上时:
|
||||
|
||||
| 键 | 描述 |
|
||||
| --- | --- |
|
||||
| <kbd>Enter</kbd> | 激活该项目并关闭菜单。 |
|
||||
| <kbd>Space</kbd> (可选) | 激活该项目并关闭菜单。 |
|
||||
| <kbd>ArrowDown</kbd> | 将焦点移动到下一项,可以选择从最后一项换行到第一项。 |
|
||||
| <kbd>ArrowUp</kbd> | 将焦点移动到上一项,可以选择从第一项换行到最后一项。 |
|
||||
| <kbd>Home</kbd> | 如果不支持箭头键换行,则将焦点移动到第一项。 |
|
||||
| <kbd>End</kbd> | 如果不支持箭头键换行,则将焦点移动到最后一项。 |
|
||||
| <kbd>Esc</kbd> | 关闭包含焦点的菜单,并将焦点返回到按钮。
|
||||
|
||||
#### WAI-ARIA 角色、状态和属性
|
||||
|
||||
* 按钮
|
||||
* 打开菜单的元素具有角色 `button`。
|
||||
* 具有角色 `button` 的元素将 `aria-haspopup` 设置为 `menu` 或 `true`。
|
||||
* 当菜单显示时,具有角色 button 的元素将 `aria-expanded` 设置为 `true`。当菜单隐藏时,建议不显示 `aria-expanded`。如果菜单隐藏时指定了 `aria-expanded`,则将其设置为 `false`。
|
||||
* 包含通过激活按钮显示的菜单项的元素具有角色 `menu`。
|
||||
* 可选地,具有角色 button 的元素具有为 `aria-controls` 指定的值,该值引用具有角色 `menu` 的元素。
|
||||
* 菜单/菜单项
|
||||
* 充当菜单的元素具有 `menu` 角色。
|
||||
* 包含在 `menu` 中的项目是包含菜单的子元素,并具有 `menuitem` 角色。menuitem
|
||||
* 当菜单项被禁用时,`aria-disabled` 设置为 `true`。
|
||||
* 具有 `menu` 角色的元素要么具有:
|
||||
* `aria-labelledby` 设置为引用控制其显示的 `menuitem` 或 `button` 的值。
|
||||
* 由 `aria-label` 提供的标签。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
由于所有面向用户的字符串都由用户提供,因此字符串可以按原样显示。但是,请注意,某些语言的某些字符串可能很长,因此应该截断或换行溢出文本。文本不应溢出菜单/按钮。
|
||||
|
||||
对于 RTL 语言,菜单按钮和内容必须水平翻转。为了实现这一点,菜单组件可以接收一个 `direction` 配置选项/prop,并根据该值呈现内容。
|
||||
|
||||

|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
* 主题示例
|
||||
* [Dropdowns · Bootstrap v5.3](https://getbootstrap.com/docs/5.3/components/dropdowns)
|
||||
* [React Menu component - Material UI](https://mui.com/material-ui/react-menu/)
|
||||
* 无头示例
|
||||
* [Dropdown Menu — Radix UI](https://www.radix-ui.com/docs/primitives/components/dropdown-menu)
|
||||
* [Menu Button — Reach UI](https://reach.tech/menu-button)
|
||||
* [Menu (Dropdown) - Headless UI](https://headlessui.com/react/menu)
|
||||
* Aria 编写实践指南 (APG)
|
||||
* [Menu Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/)
|
||||
* [Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "950b728a",
|
||||
"excerpt": "a7835608"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"ccb1c5da",
|
||||
"12c37bd9",
|
||||
"91182ae1",
|
||||
"c477a2fc",
|
||||
"fdd4f4bb",
|
||||
"9ec6d64c",
|
||||
"72c0d9d8"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"ccb1c5da",
|
||||
"12c37bd9",
|
||||
"91182ae1",
|
||||
"c477a2fc",
|
||||
"fdd4f4bb",
|
||||
"9ec6d64c",
|
||||
"72c0d9d8"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
title: 电子商务市场(例如亚马逊)
|
||||
excerpt: 设计一个像亚马逊和eBay这样的电子商务市场网站
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个电子商务网站,允许用户浏览产品并购买它们。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 现实生活中的例子
|
||||
|
||||
* https://www.amazon.com
|
||||
* https://www.ebay.com
|
||||
* https://www.walmart.com
|
||||
* https://www.flipkart.com
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"e2b5d8d2",
|
||||
"795e2f0b",
|
||||
"8c024f5a",
|
||||
"35ce9c84",
|
||||
"f2ff0a71",
|
||||
"9181a833",
|
||||
"28468d0c",
|
||||
"b7e8295b",
|
||||
"d7f8c478",
|
||||
"cc56802d",
|
||||
"98783b14",
|
||||
"624c8ff4",
|
||||
"20648958",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"10658060",
|
||||
"91d66590",
|
||||
"567dacc0",
|
||||
"d58b2ea",
|
||||
"1f797878",
|
||||
"1f4fa011",
|
||||
"8b2f16f3",
|
||||
"aa94fdea",
|
||||
"5a7720af",
|
||||
"131936b0",
|
||||
"5fa82381",
|
||||
"5df0cd3a",
|
||||
"c6fc861b",
|
||||
"7489940d",
|
||||
"3810ec6d",
|
||||
"c7b0c75e",
|
||||
"b10b5a",
|
||||
"d3b64c95",
|
||||
"5177f2b7",
|
||||
"f30e5a22",
|
||||
"c354e0f5",
|
||||
"65fa1455",
|
||||
"2ecd5d4b",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6146e579",
|
||||
"990f8751",
|
||||
"c69fb84",
|
||||
"68c838c1",
|
||||
"fec93fe5",
|
||||
"fdf99b0",
|
||||
"febf18ea",
|
||||
"14811b48",
|
||||
"834432e",
|
||||
"f53f3a8e",
|
||||
"1246ea73",
|
||||
"cb2fa491",
|
||||
"696694d7",
|
||||
"65a17d53",
|
||||
"51b448a1",
|
||||
"30539191",
|
||||
"fdf917a6",
|
||||
"9d3cef9f",
|
||||
"3d07c957",
|
||||
"f3b52926",
|
||||
"cb2fa491",
|
||||
"13edc8b3",
|
||||
"65a17d53",
|
||||
"efb0a4ff",
|
||||
"503f6ab6",
|
||||
"524c6ff3",
|
||||
"cb2fa491",
|
||||
"785a2c19",
|
||||
"65a17d53",
|
||||
"5e46f67b",
|
||||
"4e5cc30a",
|
||||
"3b2d14ba",
|
||||
"7e2aa069",
|
||||
"cb2fa491",
|
||||
"b20b8ff0",
|
||||
"65a17d53",
|
||||
"5e46f67b",
|
||||
"f700f722",
|
||||
"df955b9b",
|
||||
"66aa55f",
|
||||
"cb2fa491",
|
||||
"231bb84c",
|
||||
"65a17d53",
|
||||
"5e46f67b",
|
||||
"e807ad71",
|
||||
"bba2657",
|
||||
"807ec63b",
|
||||
"cb2fa491",
|
||||
"9552fd69",
|
||||
"65a17d53",
|
||||
"cab196c1",
|
||||
"e635d8af",
|
||||
"40808d83",
|
||||
"ea23b7bf",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"9846f084",
|
||||
"b6c24598",
|
||||
"41f5957f",
|
||||
"56afdd61",
|
||||
"751023e0",
|
||||
"2ed0ef63",
|
||||
"ad7c2a42",
|
||||
"7f4c114",
|
||||
"7ca2a1c1",
|
||||
"76341d12",
|
||||
"6eb98b04",
|
||||
"931873f8",
|
||||
"64386249",
|
||||
"8fb573c",
|
||||
"44b27fd4",
|
||||
"4cc6914e",
|
||||
"eccfe00e",
|
||||
"b38b01a8",
|
||||
"5f70f5af",
|
||||
"2a309801",
|
||||
"b3d02b0",
|
||||
"fcb0da62",
|
||||
"746ba498",
|
||||
"e7492d45",
|
||||
"fd7c34cd",
|
||||
"e00651d7",
|
||||
"99d0f453",
|
||||
"21d9b04b",
|
||||
"69f40551",
|
||||
"bfde1d8e",
|
||||
"cecbba01",
|
||||
"4e9685b6",
|
||||
"29285ab1",
|
||||
"d4553e8a",
|
||||
"be740c04",
|
||||
"4a900f8c",
|
||||
"df0ab77a",
|
||||
"1ef79c52",
|
||||
"62b1881f",
|
||||
"d24f75c1",
|
||||
"d6e19814",
|
||||
"c094c147",
|
||||
"db7a8781",
|
||||
"27dba887",
|
||||
"6477b5fd",
|
||||
"ecb3c2e8",
|
||||
"df29b84a",
|
||||
"aee4ee90",
|
||||
"44564b7d",
|
||||
"2d306931",
|
||||
"66605fca",
|
||||
"55399aa",
|
||||
"6022cd8d",
|
||||
"bcefde4b",
|
||||
"7305d9fb",
|
||||
"94ea7ece",
|
||||
"e25a7e12",
|
||||
"ab34bd00",
|
||||
"490d54b7",
|
||||
"13d4100a",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"f51ee279",
|
||||
"97ed4a3a",
|
||||
"9c98cd53"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"e2b5d8d2",
|
||||
"795e2f0b",
|
||||
"8c024f5a",
|
||||
"35ce9c84",
|
||||
"f2ff0a71",
|
||||
"9181a833",
|
||||
"28468d0c",
|
||||
"b7e8295b",
|
||||
"d7f8c478",
|
||||
"cc56802d",
|
||||
"98783b14",
|
||||
"624c8ff4",
|
||||
"20648958",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"10658060",
|
||||
"91d66590",
|
||||
"567dacc0",
|
||||
"d58b2ea",
|
||||
"1f797878",
|
||||
"1f4fa011",
|
||||
"8b2f16f3",
|
||||
"aa94fdea",
|
||||
"5a7720af",
|
||||
"131936b0",
|
||||
"5fa82381",
|
||||
"5df0cd3a",
|
||||
"c6fc861b",
|
||||
"7489940d",
|
||||
"3810ec6d",
|
||||
"c7b0c75e",
|
||||
"b10b5a",
|
||||
"d3b64c95",
|
||||
"5177f2b7",
|
||||
"f30e5a22",
|
||||
"c354e0f5",
|
||||
"65fa1455",
|
||||
"2ecd5d4b",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6146e579",
|
||||
"990f8751",
|
||||
"c69fb84",
|
||||
"68c838c1",
|
||||
"fec93fe5",
|
||||
"fdf99b0",
|
||||
"febf18ea",
|
||||
"14811b48",
|
||||
"834432e",
|
||||
"f53f3a8e",
|
||||
"1246ea73",
|
||||
"cb2fa491",
|
||||
"696694d7",
|
||||
"65a17d53",
|
||||
"51b448a1",
|
||||
"30539191",
|
||||
"fdf917a6",
|
||||
"9d3cef9f",
|
||||
"3d07c957",
|
||||
"f3b52926",
|
||||
"cb2fa491",
|
||||
"13edc8b3",
|
||||
"65a17d53",
|
||||
"efb0a4ff",
|
||||
"503f6ab6",
|
||||
"524c6ff3",
|
||||
"cb2fa491",
|
||||
"785a2c19",
|
||||
"65a17d53",
|
||||
"5e46f67b",
|
||||
"4e5cc30a",
|
||||
"3b2d14ba",
|
||||
"7e2aa069",
|
||||
"cb2fa491",
|
||||
"b20b8ff0",
|
||||
"65a17d53",
|
||||
"5e46f67b",
|
||||
"f700f722",
|
||||
"df955b9b",
|
||||
"66aa55f",
|
||||
"cb2fa491",
|
||||
"231bb84c",
|
||||
"65a17d53",
|
||||
"5e46f67b",
|
||||
"e807ad71",
|
||||
"bba2657",
|
||||
"807ec63b",
|
||||
"cb2fa491",
|
||||
"9552fd69",
|
||||
"65a17d53",
|
||||
"cab196c1",
|
||||
"e635d8af",
|
||||
"40808d83",
|
||||
"ea23b7bf",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"9846f084",
|
||||
"b6c24598",
|
||||
"41f5957f",
|
||||
"56afdd61",
|
||||
"751023e0",
|
||||
"2ed0ef63",
|
||||
"ad7c2a42",
|
||||
"7f4c114",
|
||||
"7ca2a1c1",
|
||||
"76341d12",
|
||||
"6eb98b04",
|
||||
"931873f8",
|
||||
"64386249",
|
||||
"8fb573c",
|
||||
"44b27fd4",
|
||||
"4cc6914e",
|
||||
"eccfe00e",
|
||||
"b38b01a8",
|
||||
"5f70f5af",
|
||||
"2a309801",
|
||||
"b3d02b0",
|
||||
"fcb0da62",
|
||||
"746ba498",
|
||||
"e7492d45",
|
||||
"fd7c34cd",
|
||||
"e00651d7",
|
||||
"99d0f453",
|
||||
"21d9b04b",
|
||||
"69f40551",
|
||||
"bfde1d8e",
|
||||
"cecbba01",
|
||||
"4e9685b6",
|
||||
"29285ab1",
|
||||
"d4553e8a",
|
||||
"be740c04",
|
||||
"4a900f8c",
|
||||
"df0ab77a",
|
||||
"1ef79c52",
|
||||
"62b1881f",
|
||||
"d24f75c1",
|
||||
"d6e19814",
|
||||
"c094c147",
|
||||
"db7a8781",
|
||||
"27dba887",
|
||||
"6477b5fd",
|
||||
"ecb3c2e8",
|
||||
"df29b84a",
|
||||
"aee4ee90",
|
||||
"44564b7d",
|
||||
"2d306931",
|
||||
"66605fca",
|
||||
"55399aa",
|
||||
"6022cd8d",
|
||||
"bcefde4b",
|
||||
"7305d9fb",
|
||||
"94ea7ece",
|
||||
"e25a7e12",
|
||||
"ab34bd00",
|
||||
"490d54b7",
|
||||
"13d4100a",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"f51ee279",
|
||||
"97ed4a3a",
|
||||
"9c98cd53"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
## 需求探索
|
||||
|
||||
### 需要支持哪些核心功能?
|
||||
|
||||
* 浏览产品。
|
||||
* 将产品添加到购物车。
|
||||
* 成功结账。
|
||||
|
||||
### 网站有哪些页面?
|
||||
|
||||
* 产品列表页 (PLP)
|
||||
* 产品详情页 (PDP)
|
||||
* 购物车页面
|
||||
* 结账页面
|
||||
|
||||
### PLP 和 PDP 上将显示哪些产品详细信息?
|
||||
|
||||
* PLP:产品名称、产品图片、价格。
|
||||
* PDP:产品名称、产品图片(多个)、产品描述、价格。
|
||||
|
||||
### 用户人口统计数据是什么样的?
|
||||
|
||||
广泛年龄范围的国际用户:美国、亚洲、欧洲等。
|
||||
|
||||
### 非功能性需求是什么?
|
||||
|
||||
每个页面应在 2 秒内加载。与页面元素的交互应快速响应。
|
||||
|
||||
### 应用程序将在哪些设备上使用?
|
||||
|
||||
所有可能的设备:笔记本电脑、平板电脑、手机等。
|
||||
|
||||
### 用户是否必须登录才能购买?
|
||||
|
||||
用户可以无需登录以访客身份购买。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高层设计
|
||||
|
||||

|
||||
|
||||
### 组件职责
|
||||
|
||||
* **服务器**:提供 HTTP API 以获取产品数据、购物车商品、修改购物车和创建订单。
|
||||
* **控制器**:控制应用程序内的数据流,并向服务器发出网络请求。
|
||||
* **客户端存储**:存储整个应用程序所需的数据。由于有许多页面包含数据,并且存在一定量的数据重叠,因此客户端存储对于在页面各部分之间以及页面之间共享数据非常有用。
|
||||
* **页面**:
|
||||
* **产品列表**:显示可以添加到购物车的商品列表。
|
||||
* **产品详情**:显示单个产品的详细信息以及其他详细信息。
|
||||
* **购物车**:显示已添加的购物车商品,并允许更改数量和删除已添加的商品。
|
||||
* **结账**:显示用户必须完成的地址和付款表格才能下订单。
|
||||
|
||||
### 服务器端渲染还是客户端渲染?
|
||||
|
||||
首先,让我们了解这两个术语的含义:
|
||||
|
||||
* **服务器端渲染 (SSR)**:构建网站的传统方式,服务器获取所有必要的数据,使用它们创建最终标记,并在用户每次访问页面时发送 HTML。大部分渲染工作在服务器上完成。
|
||||
* **客户端渲染 (CSR)**:服务器发送包含用于引导应用程序的 JavaScript 的初始 HTML。然后,客户端获取必要的数据,将其与模板结合起来并创建最终页面,所有这些都在浏览器中完成。CSR 通常与单页应用程序模型一起使用,后续导航不需要完全刷新页面。大部分渲染工作在客户端完成。
|
||||
|
||||
SSR 的好处:
|
||||
|
||||
* 性能通常更好,首次内容绘制分数很高,并且 SSR 页面比 CSR 页面显示得更快。
|
||||
* 累积布局偏移分数较低,因为最终的 HTML 已经存在。
|
||||
* 与静态站点生成相比,SSR 允许个性化页面(特定于用户的内容)。个性化是电子商务平台扩展的重要因素。
|
||||
|
||||
SSR 的缺点:
|
||||
|
||||
* 页面切换速度较慢,因为每次请求都必须在服务器上构建整个页面。
|
||||
|
||||
SEO 对于电子商务网站很重要,因此 SSR 应该优先考虑。
|
||||
|
||||
### 单页应用程序 (SPA) 还是多页应用程序 (MPA)?
|
||||
|
||||
SPA 默认使用 CSR,MPA 默认使用 SSR。虽然 CSR 和 SSR 是渲染的两个极端,但在中间某个地方存在一种称为通用渲染(或带有水合的 SSR)的混合模式,服务器渲染完整的 HTML,但之后,渲染和导航变为客户端。
|
||||
|
||||
大多数通用渲染站点使用流行的 UI 框架(例如 React、Vue 和 Angular),并且页面需要在初始加载后进行水合(添加事件处理程序)。水合也会带来[双重数据问题](https://web.dev/rendering-on-the-web/#a-rehydration-problem-one-app-for-the-price-of-two)。
|
||||
|
||||
应该使用哪种应用程序架构?最重要的因素是正在使用 SSR,SPA 还是 MPA 并不重要。如上所示,两者都是可行的,只要您的网站具有良好的性能。
|
||||
|
||||
实现以下渲染和架构选择的现实世界技术:
|
||||
|
||||
| | SSR | CSR |
|
||||
| --- | ---------------------------------- | -------------------------------- |
|
||||
| SPA | Next.js, Remix, Nuxt | Create React App |
|
||||
| MPA | Ruby on Rails, Django<sup>\*</sup> | This combination isn't practical |
|
||||
|
||||
<sup>\*</sup> 指的是服务器端 Web 框架的默认模式,该模式渲染
|
||||
HTML 并在服务器端完成路由。
|
||||
|
||||
### 顶级电子商务网站使用什么
|
||||
|
||||
让我们看看实际的电子商务网站及其渲染选择:
|
||||
|
||||
| | 架构 | 渲染 | UI 框架 |
|
||||
| -------- | ------------ | --------- | ---------------------------- |
|
||||
| 亚马逊 | MPA | SSR | 内部 |
|
||||
| eBay | MPA | SSR | [Marko](https://markojs.com) |
|
||||
| 沃尔玛 | SPA | SSR | React |
|
||||
| Flipkart | SPA | SSR | React |
|
||||
|
||||
**所有这些电子商务网站都使用 SSR!** 这表明对电子商务网站使用 SSR 的重要性。
|
||||
|
||||
下面的讨论假设我们将使用 SSR + SPA 的通用渲染方法。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
由于用户流程的复杂性跨越多个页面,电子商务网站涉及相当多的实体。
|
||||
|
||||
| 实体 | 来源 | 属于 | 字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ProductList` | 服务器 | 产品列表页面 | `products` ( `Product` 列表), `pagination` (分页元数据) |
|
||||
| `Product` | 服务器 | 产品列表页面、产品详情页面 | `name`、`description`、`unit_price`、`currency`、`primary_image`、`image_urls` |
|
||||
| `Cart` | 服务器 | 客户端存储 | `items` ( `CartItem` 列表), `total_price`、`currency` |
|
||||
| `CartItem` | 服务器 | 客户端存储 | `quantity`、`product`、`price`、`currency` |
|
||||
| `AddressDetails` | 用户输入(客户端) | 结账页面 | `name`、`country`、`street`、`city` 等 |
|
||||
| `PaymentDetails` | 用户输入(客户端) | 结账页面 | `card_number`、`card_expiry`、`card_cvv` |
|
||||
|
||||
讨论的几点:
|
||||
|
||||
* `Cart` 实体属于客户端存储,因为某些网站可能希望在导航栏中显示购物车商品数量,或者弹出一个窗口,允许用户快速访问购物车商品并进行修改。 如果没有这种需要,那么购物车属于购物车页面是可以接受的。
|
||||
* `Cart` 和 `CartItem` 分别具有 `total_price` 和 `price` 字段,这些字段作为服务器响应的一部分获取,而不是让客户端计算价格(将数量乘以 `unit_price`),以便可以灵活地应用批量购买或使用促销代码的折扣。 价格计算逻辑在服务器上定义,因此最终价格应在服务器上计算,客户端应依赖服务器来计算总价,而不是进行自己的计算。
|
||||
* 由于我们的网站面向国际受众,我们应该以用户的货币显示本地化价格,因此需要 `currency` 字段。
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
我们需要以下 HTTP API:
|
||||
|
||||
1. 产品信息
|
||||
* 获取产品列表
|
||||
* 获取特定产品的详细信息
|
||||
2. 购物车修改
|
||||
* 将产品添加到购物车
|
||||
* 更改购物车中产品的数量
|
||||
* 从购物车中删除产品
|
||||
3. 完成订单
|
||||
|
||||
我们将省略讨论客户端组件之间的 API,因为数据格式和功能与 HTTP API 类似。
|
||||
|
||||
我们还可以假设用户最多只有一个购物车,并且可以在服务器上检索用户的当前购物车。 因此,我们可以省略将购物车 ID 作为与购物车修改相关的任何 API 的参数。
|
||||
|
||||
### 获取产品列表
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| HTTP 方法 | `GET` |
|
||||
| 路径 | `/products` |
|
||||
| 描述 | 获取产品列表。 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `size` | number | 每页的结果数 |
|
||||
| `page` | number | 要获取的页码 |
|
||||
| `country` | string | 用户的国家/地区,确定货币 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"size": 5,
|
||||
"page": 2,
|
||||
"total_pages": 4,
|
||||
"total": 20
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": 123, // 产品 ID。
|
||||
"name": "棉质 T 恤",
|
||||
"primary_image": "https://www.greatcdn.com/img/t-shirt.jpg",
|
||||
"unit_price": 12,
|
||||
"currency": "USD"
|
||||
}
|
||||
// ... 更多产品。
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
我们在这里使用基于偏移量的分页,而不是基于游标的分页,因为:
|
||||
|
||||
1. 拥有页码对于在搜索结果之间导航和跳转到特定页面很有用。
|
||||
2. 产品结果不会受到结果过时的影响,因为新产品不会添加得那么快/频繁。
|
||||
3. 了解总共有多少结果很有用。
|
||||
|
||||
有关基于偏移的分页和基于游标的分页的更深入比较,请参阅 [新闻提要系统设计文章](/questions/system-design/news-feed-facebook)。
|
||||
|
||||
### 获取产品详情
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | --------------------------------- |
|
||||
| HTTP 方法 | `GET` |
|
||||
| 路径 | `/products/{productId}` |
|
||||
| 描述 | 获取产品详情。 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| ----------- | ------ | --------------------------------------------- |
|
||||
| `productId` | number | 要获取的产品的 ID |
|
||||
| `country` | string | 用户的国家/地区,决定货币种类 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123, // 产品 ID。
|
||||
"name": "棉质 T 恤",
|
||||
"primary_image": "https://www.greatcdn.com/img/t-shirt.jpg",
|
||||
"image_urls": [
|
||||
"https://www.greatcdn.com/img/t-shirt.jpg",
|
||||
"https://www.greatcdn.com/img/t-shirt-black.jpg",
|
||||
"https://www.greatcdn.com/img/t-shirt-red.jpg"
|
||||
],
|
||||
"unit_price": 12,
|
||||
"currency": "USD"
|
||||
}
|
||||
```
|
||||
|
||||
### 将产品添加到购物车
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ------------------------- |
|
||||
| HTTP 方法 | `POST` |
|
||||
| 路径 | `/cart/products` |
|
||||
| 描述 | 将产品添加到购物车 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| ----------- | ------ | --------------------------- |
|
||||
| `productId` | number | 要添加的产品的 ID |
|
||||
| `quantity` | number | 要添加的商品数量 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
返回更新后的购物车对象。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 789, // 购物车 ID。
|
||||
"total_price": 24,
|
||||
"currency": "USD",
|
||||
"items": [
|
||||
{
|
||||
"quantity": 2,
|
||||
"price": 24,
|
||||
"currency": "USD",
|
||||
"product": {
|
||||
"id": 123, // 产品 ID。
|
||||
"name": "棉质 T 恤",
|
||||
"primary_image": "https://www.greatcdn.com/img/t-shirt.jpg"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 更改购物车中产品的数量
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ---------------------------------------- |
|
||||
| HTTP 方法 | `PUT` |
|
||||
| 路径 | `/cart/products/{productId}/` |
|
||||
| 描述 | 更改购物车中产品的数量 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| ----------- | ------ | ---------------------------- |
|
||||
| `productId` | number | 要修改的产品的 ID |
|
||||
| `quantity` | number | 产品的新数量 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
返回更新后的购物车对象。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 789, // 购物车 ID。
|
||||
"total_price": 24,
|
||||
"currency": "USD",
|
||||
"items": [
|
||||
{
|
||||
"quantity": 3,
|
||||
"price": 36,
|
||||
"currency": "USD",
|
||||
"product": {
|
||||
"id": 123, // 产品 ID。
|
||||
"name": "棉质 T 恤",
|
||||
"primary_image": "https://www.greatcdn.com/img/t-shirt.jpg"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 从购物车中删除产品
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ------------------------------ |
|
||||
| HTTP 方法 | `DELETE` |
|
||||
| 路径 | `/cart/products/{productId}` |
|
||||
| 描述 | 从购物车中删除产品 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| ----------- | ------ | --------------------------- |
|
||||
| `productId` | number | 要删除的产品的 ID |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
返回更新后的购物车对象。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 789, // 购物车 ID。
|
||||
"total_price": 0,
|
||||
"currency": "USD",
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
### 下订单
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | --------------------------- |
|
||||
| HTTP 方法 | `POST` |
|
||||
| 路径 | `/order` |
|
||||
| 描述 | 从购物车创建订单 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `cartID` | number | 包含商品的购物车的 ID |
|
||||
| `address_details` | object | 包含地址字段的对象 |
|
||||
| `payment_details` | object | 包含支付方式字段(信用卡)的对象 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
成功创建订单后,将返回订单对象。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 456, // 订单 ID。
|
||||
"total_price": 36,
|
||||
"currency": "USD",
|
||||
"items": [
|
||||
// ... 与购物车相同的商品。
|
||||
],
|
||||
"address_details": {
|
||||
"name": "John Doe",
|
||||
"country": "US",
|
||||
"address": "1600 Market Street",
|
||||
"city": "San Francisco"
|
||||
// ... 其他地址字段。
|
||||
},
|
||||
"payment_details": {
|
||||
// 仅显示后 4 位数字。
|
||||
// 无论如何,我们不应该存储未加密的信用卡号。
|
||||
"card_last_four_digits": "1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
* 考虑到我们是否要针对回头客进行优化,我们可能需要将地址和付款详细信息保存在购物车对象中,这样在填写完结账表单但未下单就放弃购物车的用户就可以从他们离开的地方继续,而无需再次填写表单。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### 性能
|
||||
|
||||
性能对于电子商务网站至关重要。看似微小的性能改进可以带来可观的收入和转化率增长。 [Google 和 Deloitte 的一项研究](https://web.dev/milliseconds-make-millions/) 表明,即使加载时间缩短 0.1 秒,也可以提高整个购买渠道的转化率。 [Google 的 web.dev 网站上有很多关于如何提高网站性能以提高转化率的案例研究](https://web.dev/tags/case-study/)。
|
||||
|
||||
#### 一般性能提示
|
||||
|
||||
* 按路由/页面拆分 JavaScript。
|
||||
* 将内容拆分为单独的部分,并优先考虑首屏内容,同时延迟加载首屏以下的内容。
|
||||
* 延迟加载非关键 JavaScript(例如,显示模态框、对话框等所需的代码)。
|
||||
* 悬停链接/按钮时,预取下一页所需的 JavaScript 和数据。
|
||||
* 当用户悬停在 PLP 中的项目上时,预取 PDP 所需的完整产品详细信息。
|
||||
* 在购物车页面上预取结账页面。
|
||||
* 使用延迟加载和自适应加载优化图像。
|
||||
* 预取热门搜索结果。
|
||||
|
||||
#### 核心 Web Vital
|
||||
|
||||
了解各种核心 Web Vital 指标、它们是什么以及如何改进它们。
|
||||
|
||||
* [最大内容绘制 (LCP)](https://web.dev/lcp/):在页面首次开始加载时,视口中可见的最大图像或文本块的渲染时间。
|
||||
* 优化性能 – 加载 JavaScript、CSS、图像、字体等。
|
||||
* [首次输入延迟 (FID)](https://web.dev/fid/):衡量加载响应能力,因为它量化了用户在尝试与无响应页面交互时感受到的体验。 低 FID 有助于确保页面可用。
|
||||
* 减少页面加载时需要执行的 JavaScript 量。
|
||||
* [累积布局偏移 (CLS)](https://web.dev/cls/):衡量视觉稳定性,因为它有助于量化用户体验意外布局偏移的频率。 低 CLS 有助于确保页面令人愉悦。
|
||||
* 在图像和视频元素上包含大小属性或使用 CSS [`aspect-ratio`](https://developer.mozilla.org/docs/Web/CSS/aspect-ratio) 为这些元素保留空间,以在加载图像时为图像保留所需的空间。 使用 CSS [`min-height`](https://developer.mozilla.org/docs/Web/CSS/min-height) 以最大限度地减少元素延迟加载时的布局偏移。
|
||||
|
||||
### 搜索引擎优化
|
||||
|
||||
SEO 对于电子商务网站来说非常重要,因为自然搜索是人们发现产品的主要方式。
|
||||
|
||||
* PDP 应该为描述、关键字和 [开放图谱标签](https://ahrefs.com/blog/open-graph-meta-tags/) 拥有适当的 `<title>` 和 `<meta>` 标签。
|
||||
* 生成 `sitemap.xml` 以告知爬虫网站的可用页面。
|
||||
* 使用 [JSON 结构化数据](https://web.dev/structured-data/) 帮助搜索引擎了解您页面上的内容类型。 对于电子商务案例,[`Product` 类型](https://developers.google.com/search/docs/appearance/structured-data/product) 将是最相关的。
|
||||
* 对页面上的元素使用语义标记,这也有助于可访问性。
|
||||
* 确保快速加载时间,以帮助网站在 Google 搜索中获得更好的排名。
|
||||
* 使用 SSR 以获得更好的 SEO,因为搜索引擎可以索引内容,而无需等待页面渲染(在 CSR 的情况下)。
|
||||
* 预先生成热门搜索或列表的页面。
|
||||
|
||||
[旅行预订 (Airbnb) 系统设计文章](/questions/system-design/travel-booking-airbnb) 详细介绍了 SEO。
|
||||
|
||||
### 图片
|
||||
|
||||
图片是页面大小的最大贡献者之一,在图片密集型电子商务网站上,提供优化的图片绝对至关重要,因为每个产品至少有一张图片。
|
||||
|
||||
* 使用 [WebP 图像格式](https://web.dev/serve-images-webp/),这是目前最有效的图像格式。 eBay 在其所有 Web、Android 和 iOS 应用程序中使用 WebP 格式。 确保您能够在高层次上阐明 WebP 格式优越的原因。
|
||||
* 图像应托管在 CDN 上。
|
||||
* 定义图像的优先级,并将它们分为关键资产和非关键资产。
|
||||
* 延迟加载首屏以下的图像。
|
||||
* 对非关键图像使用 `<img loading="lazy">`。
|
||||
* 尽早加载关键图像。
|
||||
* 将图像作为数据 blob 内联到 HTML 中,这样就不需要发出单独的 HTTP 请求来获取图像。
|
||||
* 使用 `<link rel="preload">` 以便它们尽快下载。
|
||||
* 图像的 [自适应加载](https://web.dev/adaptive-loading-cds-2019/),为在快速网络上的设备加载高质量图像,并为在慢速网络上的设备使用较低质量的图像。
|
||||
|
||||
### 表单优化
|
||||
|
||||
填写表格是结账流程的重要组成部分,也是一个非常麻烦的部分。 这是结账流程的最后一步,做好结账体验将极大地帮助提高转化率。
|
||||
|
||||
对于移动设备来说,填写表格尤其痛苦,必须格外注意优化移动设备的表格。结账时需要填写两种表格,分别是送货地址表格和信用卡表格。
|
||||
|
||||
#### 特定国家/地区的地址表格
|
||||
|
||||
不同的国家/地区有不同的地址格式。为了优化全球运输,拥有本地化的地址表格极大地有助于提高转化率,并且用户在填写地址表格时不会因为不知道如何理解某些字段而放弃。例如:
|
||||
|
||||
* “邮政编码”在英国被称为“邮政编码”。
|
||||
* 日本没有州,只有县。
|
||||
* 不同的国家/地区有自己的邮政/邮政编码格式,需要不同的验证。
|
||||
|
||||
必须找出这些特定于国家/地区的知识并自行构建这些知识是很麻烦的,这就是像 [Stripe Checkout](https://checkout.stripe.dev/preview) 这样的服务通过提供本地化的结账表格来提供帮助的地方。用户将在 Stripe 的平台上完成其余的支付流程。
|
||||
|
||||
**不同国家/地区的 Stripe 结账表格示例**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>美国</th>
|
||||
<th>英国</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Stripe checkout for US"
|
||||
src="/img/questions/e-commerce-amazon/stripe-checkout-address-us.png"
|
||||
style={{
|
||||
margin: '0 !important',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<img
|
||||
alt="Stripe checkout for UK"
|
||||
src="/img/questions/e-commerce-amazon/stripe-checkout-address-uk.png"
|
||||
style={{
|
||||
margin: '0 !important',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
*进一步阅读:[Frank's Compulsive Guide to Postal Addresses](http://www.columbia.edu/~fdc/postal/) 提供了有用的链接,并为 200 多个国家/地区的地址格式提供了广泛的指导*
|
||||
|
||||
#### 优化自动填充
|
||||
|
||||
填写表格,尤其是长表格,容易出现拼写错误。大多数现代浏览器都具有自动填充功能,它们通过使用先前填写的类似表格中的值来帮助用户更快地输入数据并避免再次填写相同的表格数据。
|
||||
|
||||
通过为送货地址表格和信用卡表格的表单 `<input>` 指定正确的 `type` 和 `autocomplete` 值,帮助用户自动填充其地址表格。
|
||||
|
||||
**送货地址表格**
|
||||
|
||||
| 字段 | `type` | `autocomplete` | 其他 |
|
||||
| --- | --- | --- | --- |
|
||||
| 姓名 | `text` | `shipping name` | `autocorrect="off"` `spellcheck="false"` |
|
||||
| 国家/地区 | 使用 `<select>` | `shipping country` | N/A |
|
||||
| 地址行 1 | `text` | `shipping address-line1` | `autocorrect="off"` `spellcheck="false"` |
|
||||
| 地址行 2 | `text` | `shipping address-line1` | `autocorrect="off"` `spellcheck="false"` |
|
||||
| 城市 | `text` | `shipping address-level2` | `autocorrect="off"` `spellcheck="false"` |
|
||||
| 州/省/自治区 | 使用 `<select>` | `shipping address-level1` | N/A |
|
||||
| 邮政编码 | `text` | `shipping postal-code` | `autocorrect="off"` `spellcheck="false"` `inputmode="numeric"` |
|
||||
|
||||
**信用卡表格**
|
||||
|
||||
| 字段 | `type` | `autocomplete` | 其他 |
|
||||
| --- | --- | --- | --- |
|
||||
| 卡号 | `text` | `cc-number` | `autocorrect="off"` `spellcheck="false"` `inputmode="numeric"` |
|
||||
| 卡片有效期 | `text` | `cc-exp` | `autocorrect="off"` `spellcheck="false"` `inputmode="numeric"` |
|
||||
| 卡片 CVC | `text` | `cc-csc` | `autocorrect="off"` `spellcheck="false"` `inputmode="numeric"` |
|
||||
|
||||
**笔记**
|
||||
|
||||
* `inputmode="numeric"` 为具有屏幕键盘的设备提供浏览器提示,以帮助它们决定显示哪个键盘。与 `<input type="number">` 不同,`inputmode="numeric"` 不会阻止用户键入非数值,它们只会影响显示的键盘。由于这些数字字段与数量无关,因此使用 `<input type="number">` 时出现的 chevron 并不太有用。`<input type="text" inputmode="numeric">` 也适用于 `maxlength`/`minlength`/`pattern` 属性,但不适用于 `<input type="number>`。
|
||||
|
||||
阅读有关表格的更多信息:
|
||||
|
||||
* [自动填充 | web.dev](https://web.dev/learn/forms/autofill/)
|
||||
* [浏览器上的自动填充:深入研究 | eBay 工程博客](https://tech.ebayinc.com/engineering/autofill-deep-dive/)。
|
||||
|
||||
#### 替代地址输入方式
|
||||
|
||||
与其让用户填写包含细粒度地址字段的表单,不如采用其他方法,这些方法可能更容易,但需要付出工程复杂性或依赖外部服务的代价:
|
||||
|
||||
1. **地址搜索/自动填充**:允许用户通过输入街道号码来搜索地址,并让他们从建议列表中进行选择。这减少了拼写错误,并且通常更快。但是,如果没有任何建议是正确的,用户仍然应该被赋予覆盖某些值的选项,这可能是由于过时的地址数据库造成的。Google Maps JavaScript API 通过 [Place Autocomplete library](https://developers.google.com/maps/documentation/javascript/place-autocomplete) 提供此功能。
|
||||
|
||||
2. **从地图中选择地址位置**:打开地图并允许用户在地图上精确定位一个位置。这对于结账地址来说不太常见,而对于叫车应用程序来说更常见。
|
||||
|
||||
#### 错误消息
|
||||
|
||||
利用客户端验证并清楚地传达任何表单错误。通过 `aria-describedby` 将错误消息连接到 `<input>`,并对错误消息使用 `aria-live="assertive"`。
|
||||
|
||||
```html
|
||||
<form>
|
||||
<div>
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
required
|
||||
minlength="6"
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
aria-describedby="name-error-message" />
|
||||
<span
|
||||
id="name-error-message"
|
||||
aria-live="assertive"
|
||||
class="name-error-message">
|
||||
Name must have at least 6 characters!
|
||||
</span>
|
||||
</div>
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
*来源:[帮助用户查找表单控件的错误消息 | web.dev](https://web.dev/learn/forms/accessibility/#help-users-find-the-error-message-for-a-form-control)*
|
||||
|
||||
#### 焦点状态
|
||||
|
||||
使当前获得焦点的表单控件在视觉上与其他表单输入不同,以帮助用户识别哪个元素正在获得焦点。
|
||||
|
||||
#### 付款和地址表单的最佳实践
|
||||
|
||||
在 web.dev 上阅读更多关于构建良好的 [付款表单](https://web.dev/learn/forms/payment/) 和 [地址表单](https://web.dev/learn/forms/address/) 以及 [一般表单最佳实践](https://web.dev/payment-and-address-form-best-practices/) 的信息。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
* 在支持的语言中翻译页面。
|
||||
* 在 `html` 标签上设置 `lang` 属性(例如 `<html lang="zh-cn">`),以告知浏览器和搜索引擎页面的语言,这有助于浏览器提供页面的翻译。
|
||||
* 通过使用 [CSS 逻辑属性](https://web.dev/learn/css/logical-properties/) 提供对 RTL 语言的支持
|
||||
* 表单
|
||||
* 对名称使用单个表单字段。
|
||||
* 允许使用各种地址格式。这在上面关于地址表单的部分中已介绍。
|
||||
|
||||
阅读更多:
|
||||
|
||||
* [国际化和本地化 | 表单 | web.dev](https://web.dev/learn/forms/internationalization/)
|
||||
* [国际化 | 设计 | web.dev](https://web.dev/learn/design/internationalization/)
|
||||
|
||||
### 可访问性
|
||||
|
||||
* 尽可能使用语义元素:标题、按钮、链接、输入,而不是样式化的 `<div>`。
|
||||
* `<img>` 标签应指定 `alt` 属性,如果商家未提供其描述,则留空。
|
||||
* 上面已经详细介绍了构建可访问表单。总而言之:
|
||||
* `<input>` 应该有关联的 `<label>`。
|
||||
* `<input>` 通过 `aria-describedby` 链接到它们的错误消息,并且错误消息通过 `aria-live="assertive"` 播报。
|
||||
* 使用正确的 `type` 的 `<input>` 和适当的与验证相关的属性,如 `pattern`、`minlength`、`maxlength`。
|
||||
* 视觉顺序与 DOM 顺序匹配。
|
||||
* 使当前获得焦点的表单控件显而易见。
|
||||
|
||||
*参考:[可访问性 | 表单 | web.dev](https://web.dev/learn/forms/accessibility/) 和 [WebAIM:创建可访问表单](https://webaim.org/techniques/forms/)*
|
||||
|
||||
### 安全性
|
||||
|
||||
由于付款详细信息高度敏感,我们必须确保网站安全:
|
||||
|
||||
* 使用 HTTPS,以便与服务器的所有通信都经过加密,并且在同一 Wi-FI 网络上的其他用户无法拦截和获取任何敏感详细信息。
|
||||
* 付款详细信息提交 API 不应使用 HTTP `GET`,因为敏感详细信息将作为查询字符串包含在请求 URL 中,该 URL 将添加到浏览历史记录中,如果浏览器与其他用户共享,则可能不安全。请改用 HTTP `POST` 或 `PUT`。
|
||||
|
||||
*来源:[安全性和隐私 | web.dev](https://web.dev/learn/forms/security-privacy/)*
|
||||
|
||||
### 用户体验
|
||||
|
||||
* 使结账页面简洁(例如,最小化的导航栏和页脚),并移除干扰以降低跳出率。
|
||||
* 允许保留购物车内容(在数据库或cookie中),因为有些人会花时间研究和考虑,只在后续会话中进行购买。您不希望他们再次将所有商品添加到购物车中。
|
||||
* 使促销代码字段不那么突出,这样没有促销代码的人就不会离开页面去搜索促销代码。那些事先有促销代码的人会努力找到促销代码输入字段。
|
||||
|
||||
{/* ### 渐进式 Web 应用程序 (TODO) */}
|
||||
|
||||
***
|
||||
|
||||
## 参考资料
|
||||
|
||||
* [在eBay.com上购物的速度 | web.dev](https://web.dev/shopping-for-speed-on-ebay/)
|
||||
* [案例研究 | web.dev](https://web.dev/tags/case-study/)
|
||||
* [Rakuten 24对核心Web Vitals的投资如何使每位访问者的收入增加了53.37%,转化率提高了33.13% | web.dev](https://web.dev/rakuten/)
|
||||
* [速度的千刀万剐](https://tech.ebayinc.com/engineering/speed-by-a-thousand-cuts/)
|
||||
* [专注于Web性能如何使Tokopedia的点击率提高了35% | web.dev](https://web.dev/tokopedia/)
|
||||
* [浏览器上的自动填充:深度解析](https://tech.ebayinc.com/engineering/autofill-deep-dive/)
|
||||
* [Web上的渲染 | web.dev](https://web.dev/rendering-on-the-web/)
|
||||
|
||||
## 变更日志
|
||||
|
||||
* 2024/08/21
|
||||
* 允许添加产品API指定`quantity`字段
|
||||
* 2024/02/27
|
||||
* 修改API路由以更符合REST规范
|
||||
* 2024/01/12
|
||||
* 修复产品详细信息页面的错误图片
|
||||
* 更新顶级电子商务网站的架构和渲染选择
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "737cfe1e",
|
||||
"excerpt": "55ff6c2e"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"c2baa595",
|
||||
"57086baa",
|
||||
"ae562b02",
|
||||
"9ec6d64c",
|
||||
"29e28848"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"c2baa595",
|
||||
"57086baa",
|
||||
"ae562b02",
|
||||
"9ec6d64c",
|
||||
"29e28848"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
title: 电子邮件客户端(例如 Microsoft Outlook)
|
||||
excerpt: 设计一个类似 Microsoft Outlook 和 Apple Mail 的桌面电子邮件客户端
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个**桌面**电子邮件客户端,在给定电子邮件提供商服务的情况下,可以发送和接收电子邮件。
|
||||
|
||||

|
||||
|
||||
区分**网络邮件**和电子邮件客户端应用程序非常重要。像 gmail.com、outlook.com、Yahoo Mail 这样的网站允许您使用浏览器访问电子邮件,这称为网络邮件。电子邮件客户端是必须安装在您计算机上的桌面应用程序,即使在离线状态下通常也可以使用。它们通常允许访问多个电子邮件服务,如 Gmail、Outlook、iCloud 等,并在应用程序中查看来自不同服务的消息。
|
||||
|
||||
### 真实案例
|
||||
|
||||
* Outlook for [Windows](https://apps.microsoft.com/store/detail/outlook-for-windows/9NRX63209R7B)/[macOS](https://apps.apple.com/sg/app/microsoft-outlook/id985367838)
|
||||
* [Apple Mail](https://support.apple.com/en-sg/guide/mail/welcome/mac)
|
||||
* [Airmail](https://airmailapp.com/)
|
||||
* [Mailspring](https://getmailspring.com/)
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"ea330b4b",
|
||||
"b5e6c41e",
|
||||
"a287c41b",
|
||||
"c9be7d85",
|
||||
"54e119ea",
|
||||
"9b009404",
|
||||
"5047f0be",
|
||||
"4b679772",
|
||||
"e356e5e2",
|
||||
"c5afbfaa",
|
||||
"d31354c5",
|
||||
"31b2ba15",
|
||||
"7a4f7aad",
|
||||
"2a7816d0",
|
||||
"7f3128ae",
|
||||
"6e8b2c89",
|
||||
"f35f6ed1",
|
||||
"8d87c55c",
|
||||
"a6ba471e",
|
||||
"5d5525fd",
|
||||
"4e8cc655",
|
||||
"1b9237a4",
|
||||
"8ecb5f1a",
|
||||
"715df798",
|
||||
"1618b3fa",
|
||||
"f7b63041",
|
||||
"94ee485",
|
||||
"29ac9098",
|
||||
"9295a6fb",
|
||||
"145faaa2",
|
||||
"abfade22",
|
||||
"f360dc60",
|
||||
"9cd4161",
|
||||
"866b45a4",
|
||||
"a7667051",
|
||||
"42e3e297",
|
||||
"23260c2b",
|
||||
"3059faae",
|
||||
"6233e022",
|
||||
"fe3e3740",
|
||||
"b6297f0a",
|
||||
"17667327",
|
||||
"13974b62",
|
||||
"fcae10f5",
|
||||
"cc21473d",
|
||||
"9267e8f8",
|
||||
"ffbfb4cf",
|
||||
"d35e7822",
|
||||
"10c9d186",
|
||||
"a330b1ba",
|
||||
"c6114236",
|
||||
"49924b07",
|
||||
"3b42ad3f",
|
||||
"eb93507d",
|
||||
"6829d4ab",
|
||||
"fcd3c760",
|
||||
"5f896c5a",
|
||||
"85b82887",
|
||||
"91d2532d",
|
||||
"863ad659",
|
||||
"402f5664",
|
||||
"c7b2065f",
|
||||
"6c7716e9",
|
||||
"139cf2e4",
|
||||
"87deb665",
|
||||
"c51e1042",
|
||||
"d37b5b85",
|
||||
"ebf56162",
|
||||
"c07b306",
|
||||
"3c4bdd68",
|
||||
"87deb665",
|
||||
"7896d3cf",
|
||||
"d37b5b85",
|
||||
"a0e01da2",
|
||||
"25ed1e0a",
|
||||
"69e7df1e",
|
||||
"87deb665",
|
||||
"bdd2115",
|
||||
"d37b5b85",
|
||||
"fe380719",
|
||||
"a411a3aa",
|
||||
"4ac25f08",
|
||||
"6101654d",
|
||||
"4313b5a7",
|
||||
"3cce7975",
|
||||
"917a38f8",
|
||||
"3a7e0e77",
|
||||
"91370d78",
|
||||
"457e292a",
|
||||
"defcae13",
|
||||
"9676402b",
|
||||
"a2e3ab11",
|
||||
"81c55cbc",
|
||||
"16948fd1",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"c175ae6d",
|
||||
"3c83b93",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"6e75bc67",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"babf5646",
|
||||
"fe26aef5",
|
||||
"cad17d5c",
|
||||
"1b233300",
|
||||
"25acf372",
|
||||
"d1d347f3",
|
||||
"9846f084",
|
||||
"7ea4cb65",
|
||||
"7dbc743b",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"a259de16"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"ea330b4b",
|
||||
"b5e6c41e",
|
||||
"a287c41b",
|
||||
"c9be7d85",
|
||||
"54e119ea",
|
||||
"9b009404",
|
||||
"5047f0be",
|
||||
"4b679772",
|
||||
"e356e5e2",
|
||||
"c5afbfaa",
|
||||
"d31354c5",
|
||||
"31b2ba15",
|
||||
"7a4f7aad",
|
||||
"2a7816d0",
|
||||
"7f3128ae",
|
||||
"6e8b2c89",
|
||||
"f35f6ed1",
|
||||
"8d87c55c",
|
||||
"a6ba471e",
|
||||
"5d5525fd",
|
||||
"4e8cc655",
|
||||
"1b9237a4",
|
||||
"8ecb5f1a",
|
||||
"715df798",
|
||||
"1618b3fa",
|
||||
"f7b63041",
|
||||
"94ee485",
|
||||
"29ac9098",
|
||||
"9295a6fb",
|
||||
"145faaa2",
|
||||
"abfade22",
|
||||
"f360dc60",
|
||||
"9cd4161",
|
||||
"866b45a4",
|
||||
"a7667051",
|
||||
"42e3e297",
|
||||
"23260c2b",
|
||||
"3059faae",
|
||||
"6233e022",
|
||||
"fe3e3740",
|
||||
"b6297f0a",
|
||||
"17667327",
|
||||
"13974b62",
|
||||
"fcae10f5",
|
||||
"cc21473d",
|
||||
"9267e8f8",
|
||||
"ffbfb4cf",
|
||||
"d35e7822",
|
||||
"10c9d186",
|
||||
"a330b1ba",
|
||||
"c6114236",
|
||||
"49924b07",
|
||||
"3b42ad3f",
|
||||
"eb93507d",
|
||||
"6829d4ab",
|
||||
"fcd3c760",
|
||||
"5f896c5a",
|
||||
"85b82887",
|
||||
"91d2532d",
|
||||
"863ad659",
|
||||
"402f5664",
|
||||
"c7b2065f",
|
||||
"6c7716e9",
|
||||
"139cf2e4",
|
||||
"87deb665",
|
||||
"c51e1042",
|
||||
"d37b5b85",
|
||||
"ebf56162",
|
||||
"c07b306",
|
||||
"3c4bdd68",
|
||||
"87deb665",
|
||||
"7896d3cf",
|
||||
"d37b5b85",
|
||||
"a0e01da2",
|
||||
"25ed1e0a",
|
||||
"69e7df1e",
|
||||
"87deb665",
|
||||
"bdd2115",
|
||||
"d37b5b85",
|
||||
"fe380719",
|
||||
"a411a3aa",
|
||||
"4ac25f08",
|
||||
"6101654d",
|
||||
"4313b5a7",
|
||||
"3cce7975",
|
||||
"917a38f8",
|
||||
"3a7e0e77",
|
||||
"91370d78",
|
||||
"457e292a",
|
||||
"defcae13",
|
||||
"9676402b",
|
||||
"a2e3ab11",
|
||||
"81c55cbc",
|
||||
"16948fd1",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"c175ae6d",
|
||||
"3c83b93",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"6e75bc67",
|
||||
"6101654d",
|
||||
"25294034",
|
||||
"babf5646",
|
||||
"fe26aef5",
|
||||
"cad17d5c",
|
||||
"1b233300",
|
||||
"25acf372",
|
||||
"d1d347f3",
|
||||
"9846f084",
|
||||
"7ea4cb65",
|
||||
"7dbc743b",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"a259de16"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
<div className="not-prose rounded-md bg-amber-500 p-4">
|
||||
<h2 className="text-sm font-medium text-white">注意:正在进行中!</h2>
|
||||
|
||||
<p className="mt-2 text-sm text-white">
|
||||
该解决方案仍在开发中,但我们希望分享草稿,以便感兴趣的用户可以从中受益,并提供反馈。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## 需求探索
|
||||
|
||||
### 需要哪些核心功能?
|
||||
|
||||
* 将电子邮件消息发送到SMTP服务器。
|
||||
* 从IMAP服务器检索电子邮件消息。
|
||||
* 访问设备上已有的电子邮件消息。
|
||||
|
||||
### 该应用程序需要支持哪些操作系统?
|
||||
|
||||
流行的操作系统:Windows、macOS和Linux/Ubuntu。
|
||||
|
||||
### 需要支持哪些电子邮件服务/帐户?
|
||||
|
||||
对于这个问题,我们不必关注这个方面。假设用户可以向预配置的SMTP/IMAP服务器发出经过身份验证的请求,以成功发送/检索电子邮件。
|
||||
|
||||
许多本地桌面电子邮件客户端(如Apple Mail、Outlook和Mailspring)允许用户连接到多个电子邮件服务(如iCloud Mail、Gmail、Exchange),以在应用程序中显示来自多个服务的电子邮件。但是,这超出了本问题的范围。
|
||||
|
||||
### 应用程序是否需要离线工作?
|
||||
|
||||
是的,如果可能的话。传出的电子邮件消息应在应用程序上线时保存并发送出去。即使离线,也应允许用户浏览和搜索设备上的电子邮件。
|
||||
|
||||
### 相同发件人和主题之间的电子邮件是否应该进行线程化?
|
||||
|
||||
消息对话的线程化会很好,但不是必需的。
|
||||
|
||||
***
|
||||
|
||||
## 背景知识
|
||||
|
||||
由于服务器请求是使用非HTTP协议(如SMTP和IMAP)进行的,电子邮件客户端应用程序与传统的Web应用程序有很大不同。面试官不太可能要求候选人熟悉常用电子邮件协议的工作方式,因此您可以假设您正在使用基于HTTP的API来发送和检索电子邮件。
|
||||
|
||||
尽管如此,为了学习,我们将介绍一些基本的电子邮件系统概念。
|
||||
|
||||
### 电子邮件系统的组成部分
|
||||
|
||||
* **邮件用户代理 (MUA)**:用户可以在其中撰写、发送、接收和阅读电子邮件的应用程序。其他非核心功能包括搜索、标记、通讯簿等。这些可以是具有图形用户界面 (Outlook、Apple Mail) 的桌面应用程序,也可以是命令行程序。
|
||||
* **邮件传输代理 (MTA)**:使用 SMTP 协议将电子邮件消息从一个主机传输到另一个主机的软件。MTA 可以存在于用户的设备和邮件服务器上。
|
||||
* **邮件服务器**:托管 MTA 并在邮箱中存储电子邮件消息的计算机。
|
||||
* **邮箱**:邮箱是一个概念实体,不一定与存储相关,并由电子邮件地址标识。它包含电子邮件消息,通常存在于邮件服务器上。
|
||||
|
||||
### 邮件传输协议
|
||||
|
||||
如果您之前设置过电子邮件客户端,您可能遇到过 SMTP、POP 和 IMAP 这些术语。SMTP 是一种用于发送消息的传出电子邮件协议,而 POP 和 IMAP 是电子邮件服务器支持的用于允许客户端检索消息的传入电子邮件协议。
|
||||
|
||||
拥有用于发送和检索消息的标准化协议的好处是,电子邮件服务可以在彼此之间发送消息,并且电子邮件客户端可以连接到任何电子邮件服务。
|
||||
|
||||
#### 简单邮件传输协议 (SMTP)
|
||||
|
||||
[简单邮件传输协议](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) 是一种通过互联网发送电子邮件消息的协议,供邮件服务器、MTA 和 MUA(非网络邮件)使用。
|
||||
|
||||
SMTP 使用一组简单的命令来传输消息,包括用于验证发件人、指定收件人以及发送消息的命令。
|
||||
|
||||
Nylas 在他们的博客文章 ["SMTP vs. Web API: The Best Methods for Sending Email"](https://www.nylas.com/blog/smtp-vs.-web-api-the-best-methods-for-sending-email/) 中详细讨论了 SMTP 中继。以下是与 SMTP 服务器通过命令行进行的 [SMTP 对话示例](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_transport_example)。以 `S:` 开头的行是从服务器发送的,以 `C:` 开头的行是从用户写入的。
|
||||
|
||||
```sh
|
||||
$ openssl s_client -connect smtp.example.com:465 -crlf
|
||||
|
||||
S: 220 smtp.example.com ESMTP Postfix
|
||||
C: HELO relay.example.org
|
||||
S: 250 Hello relay.example.org, I am glad to meet you
|
||||
C: AUTH LOGIN
|
||||
S: 334 VXNlcm5hbWU6
|
||||
C: dXNlcm5hbWUuY29t # Username encoded in Base64
|
||||
S: 334 UGFzc3dvcmQ6
|
||||
C: bXlwYXNzd29yZA== # Password encoded in Base64
|
||||
S: 235 Authentication succeeded
|
||||
C: MAIL FROM:<bob@example.org>
|
||||
S: 250 Ok
|
||||
C: RCPT TO:<alice@example.com>
|
||||
S: 250 Ok
|
||||
C: RCPT TO:<theboss@example.com>
|
||||
S: 250 Ok
|
||||
C: DATA
|
||||
S: 354 End data with <CR><LF>.<CR><LF>
|
||||
C: From: "Bob Example" <bob@example.org>
|
||||
C: To: "Alice Example" <alice@example.com>
|
||||
C: Cc: theboss@example.com
|
||||
C: Date: Tue, 15 Jan 2008 16:02:43 -0500
|
||||
C: Subject: Test message
|
||||
C:
|
||||
C: Hello Alice.
|
||||
C: This is a test message with 5 header fields and 4 lines in the message body.
|
||||
C: Your friend,
|
||||
C: Bob
|
||||
C: .
|
||||
S: 250 Ok: queued as 12345
|
||||
C: QUIT
|
||||
S: 221 Bye
|
||||
{The server closes the connection}
|
||||
```
|
||||
|
||||
SMTP 的规范可以在 [RFC5321](https://www.rfc-editor.org/rfc/rfc5321) 中找到。
|
||||
|
||||
#### 邮局协议 (POP)
|
||||
|
||||
[邮局协议](https://en.wikipedia.org/wiki/Post_Office_Protocol) 是一种用于访问邮件服务器上电子邮件消息的传统标准协议。消息仅在第一个设备访问并下载它们之前保留在服务器上。顾名思义,一旦电子邮件被下载,它通常会从服务器中删除,就像邮局在将实体邮件交付给收件人之前充当临时存储一样。
|
||||
|
||||
POP3 是 POP 的最新广泛使用的版本,但比 IMAP 等较新的协议更旧,功能也更少。POP 通常被认为不如 IMAP 可取,因为它不够灵活,并且不允许服务器端搜索或消息标记。
|
||||
|
||||
POP3 的规范可以在 [RFC1939](https://www.rfc-editor.org/rfc/rfc1939) 中找到。
|
||||
|
||||
#### Internet 消息访问协议 (IMAP)
|
||||
|
||||
[Internet 消息访问协议](https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol) 是一种用于访问邮件服务器上电子邮件消息的标准协议,最新版本为 IMAP4。IMAP 允许用户在网络邮件和电子邮件客户端中检索和管理他们的电子邮件消息,而无需将它们下载到本地计算机。IMAP 还允许用户从多个设备和位置访问他们的电子邮件,并提供服务器端搜索和消息标记等功能。
|
||||
|
||||
IMAP 解决了 POP 的许多缺点,但代价是服务器存储。Nylas 在他们的工程博客上发布了 [对 IMAP 的深入研究](https://www.nylas.com/blog/nylas-imap-therefore-i-am/),我们强烈建议您查看一下。
|
||||
|
||||
IMAP4 的规范可以在 [RFC3501](https://www.rfc-editor.org/rfc/rfc3501) 中找到。
|
||||
|
||||
#### POP vs IMAP
|
||||
|
||||
以下是一个比较 POP (POP3) 和 IMAP (IMAP4) 协议的表格。
|
||||
|
||||
| 功能 | POP3 | IMAP |
|
||||
| --- | --- | --- |
|
||||
| 事实来源 | 客户端 | 服务器 |
|
||||
| 同时客户端数量 | 一个 | 多个 |
|
||||
| 邮箱数量 | 一个 | 多个 |
|
||||
| 消息下载 | 整个消息 | 独立部分 |
|
||||
| 消息 [标记](https://www.rfc-editor.org/rfc/rfc3501#section-2.3.2)(已读、已回复、已删除) | 否 | 是 |
|
||||
| 下载后从服务器删除 | 默认是 | 否 |
|
||||
| 服务器端搜索 | 否 | 是 |
|
||||
| 服务器存储使用 | 低 | 高 |
|
||||
|
||||
*参考:[IMAP vs. POP3:有什么区别?您应该使用哪一个?](https://www.makeuseof.com/tag/pop-vs-imap/)*
|
||||
|
||||
如今,人们期望在多个设备上访问电子邮件,并在不同的客户端设备上查看一致的邮箱状态,因此 POP 的模型已经过时。 POP 的主要优点是需要更少的服务器存储空间,但这通常不是当今的问题,因为存储相对便宜。
|
||||
|
||||
IMAP是当前时代流行的电子邮件协议,但许多电子邮件客户端仍然支持从IMAP和POP服务器检索电子邮件。
|
||||
|
||||
### 电子邮件服务器配置
|
||||
|
||||
流行的电子邮件服务具有以下配置。
|
||||
|
||||
| 服务 | SMTP | IMAP | POP |
|
||||
| --- | --- | --- | --- |
|
||||
| [Gmail](https://support.google.com/mail/answer/7126229) | `smtp.gmail.com` | `imap.gmail.com` | `pop.gmail.com` |
|
||||
| [Outlook](https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-d088b986-291d-42b8-9564-9c414e2aa040) | `smtp-mail.outlook.com` | `outlook.office365.com` (端口 993) | `outlook.office365.com` (端口 995) |
|
||||
| [iCloud](https://support.apple.com/en-us/HT202304) | `smtp.mail.me.com` | `imap.mail.me.com` | 不支持 |
|
||||
|
||||
### 电子邮件流程
|
||||
|
||||
让我们假设以下用户具有各自的角色、服务和客户端类型:
|
||||
|
||||
| 用户 | 角色 | 服务 | 客户端类型 |
|
||||
| ----- | -------- | ------- | ----------- |
|
||||
| Alice | 发件人 | Gmail | 桌面应用程序 |
|
||||
| Bob | 收件人 | Outlook | 桌面应用程序 |
|
||||
| Carol | 发件人 | Gmail | Webmail |
|
||||
| David | 收件人 | Outlook | Webmail |
|
||||
|
||||
我们将详细介绍消息在各种类型的客户端之间发送的流程。
|
||||
|
||||
<figure>
|
||||

|
||||
|
||||
<figcaption>电子邮件消息从发件人到收件人的传递方式</figcaption>
|
||||
</figure>
|
||||
|
||||
**重要提示**:
|
||||
|
||||
* 邮件服务可以在同一台机器或不同的机器上运行 SMTP 服务器和 IMAP/POP 服务器。 只要邮件服务的服务器可以访问相同的电子邮件消息数据库,这个决定对我们的讨论并不重要。
|
||||
* 箭头的方向表示电子邮件消息的流向,而不是请求的来源。 IMAP/POP 请求由客户端发起,而不是从邮件服务器推送。
|
||||
* 上述情况下,Gmail 仅用于发送而非接收,因此未显示 Gmail 的 IMAP 服务器。
|
||||
* 为了简单起见,省略了 MX(邮件交换)记录的 DNS 查找阶段。 SMTP 服务器使用收件人电子邮件地址的域名来查找该域的 DNS 记录(尤其是 MX 记录),以确定邮件服务器的地址。
|
||||
* MTA 是相当通用的术语,但它们都使用 SMTP 发送消息。 SMTP 服务器是 MTA。
|
||||
|
||||
#### 电子邮件客户端 -> 电子邮件客户端
|
||||
|
||||
1. Alice (alice@gmail.com) 想给 Bob (bob@outlook.com) 发送电子邮件。
|
||||
2. Alice 的电子邮件客户端桌面应用程序将消息发送到 MTA,MTA 是在她计算机上运行的软件。
|
||||
3. Alice 的计算机的 MTA 通过 SMTP 将消息发送到 Gmail 的 SMTP 服务器 (`smtp.gmail.com`)。
|
||||
4. Gmail 的 SMTP 服务器通过 SMTP 将消息发送到 Outlook 的 SMTP 服务器 (`smtp-mail.outlook.com`),并将消息保存到 Outlook 的数据库中。
|
||||
5. Bob 的电子邮件桌面客户端向 Outlook 的 IMAP 服务器 (`outlook.office365.com:993`) 发出 IMAP 请求。
|
||||
6. Outlook 的 IMAP 服务器从数据库中检索消息,并通过 IMAP 将其发送回 Bob 的桌面客户端。
|
||||
7. Bob 的电子邮件桌面客户端显示来自 Alice 的新电子邮件消息。
|
||||
|
||||
#### 电子邮件客户端 -> Webmail
|
||||
|
||||
1. Alice (alice@gmail.com) 想给 David (david@outlook.com) 发送电子邮件。
|
||||
2. Alice 的电子邮件客户端桌面应用程序将消息发送到 MTA,MTA 是在她计算机上运行的软件。
|
||||
3. Alice 的计算机的 MTA 通过 SMTP 将消息发送到 Gmail 的 SMTP 服务器 (`smtp.gmail.com`)。
|
||||
4. Gmail 的 SMTP 服务器通过 SMTP 将消息发送到 Outlook 的 SMTP 服务器 (`smtp-mail.outlook.com`),并将消息保存到 Outlook 的数据库中。
|
||||
5. David 通过在其浏览器中访问 `https://outlook.live.com` 来访问 Outlook webmail。
|
||||
6. 托管 `outlook.live.com` 的服务器向 Outlook 的 IMAP 服务器 (`outlook.office365.com:993`) 发出 IMAP 请求。
|
||||
7. Outlook 的 IMAP 服务器从数据库中检索消息并将其发送回 `outlook.live.com`。
|
||||
8. 托管 `outlook.live.com` 的服务器通过 HTTP 将响应发送到 David 的浏览器。
|
||||
9. David 的浏览器显示来自 Alice 的新电子邮件消息。
|
||||
|
||||
#### Webmail -> 电子邮件客户端
|
||||
|
||||
1. Carol (carol@gmail.com) 想给 Bob (bob@outlook.com) 发送电子邮件。
|
||||
2. Carol 通过在其浏览器中访问 `https://www.gmail.com` 来访问 Gmail webmail。
|
||||
3. Carol 从 Gmail webmail 发送电子邮件消息,该消息向 `gmail.com` 服务器发出 HTTP 请求。
|
||||
4. `gmail.com` 服务器使用其 MTA 软件通过 SMTP 将消息发送到 Gmail 的 SMTP 服务器 (`smtp.gmail.com`)。
|
||||
5. Gmail 的 SMTP 服务器通过 SMTP 将消息发送到 Outlook 的 SMTP 服务器 (`smtp-mail.outlook.com`),并将消息保存到 Outlook 的数据库中。
|
||||
6. Bob 的电子邮件桌面客户端向 Outlook 的 IMAP 服务器 (`outlook.office365.com:993`) 发出 IMAP 请求。
|
||||
7. Outlook 的 IMAP 服务器从数据库中检索消息,并通过 IMAP 将其发送回 Bob 的桌面客户端。
|
||||
8. Bob 的电子邮件桌面客户端显示来自 Carol 的新电子邮件消息。
|
||||
|
||||
#### Webmail -> Webmail
|
||||
|
||||
1. Carol (carol@gmail.com) 想给 David (david@outlook.com) 发送电子邮件。
|
||||
2. Carol 通过在浏览器中访问 `https://www.gmail.com` 来访问 Gmail 网络邮件。
|
||||
3. Carol 从 Gmail 网络邮件发送电子邮件,这向 `gmail.com` 服务器发出 HTTP 请求。
|
||||
4. `gmail.com` 服务器使用其 MTA 软件通过 SMTP 将邮件发送到 Gmail 的 SMTP 服务器 (`smtp.gmail.com`)。
|
||||
5. Gmail 的 SMTP 服务器通过 SMTP 将邮件发送到 Outlook 的 SMTP 服务器 (`smtp-mail.outlook.com`),并将邮件保存到 Outlook 的数据库中。
|
||||
6. David 通过在浏览器中访问 `https://outlook.live.com` 来访问 Outlook 网络邮件。
|
||||
7. 托管 `outlook.live.com` 的服务器向 Outlook 的 IMAP 服务器 (`outlook.office365.com:993`) 发出 IMAP 请求。
|
||||
8. Outlook 的 IMAP 服务器从数据库中检索邮件,并通过 IMAP 将其发送回 `outlook.live.com`。
|
||||
9. 托管 `outlook.live.com` 的服务器通过 HTTP 将响应发送到 David 的浏览器。
|
||||
10. David 的浏览器显示 Carol 发来的新电子邮件。
|
||||
|
||||
### 电子邮件系统类型
|
||||
|
||||
电子邮件系统可以大致分为以下几类:
|
||||
|
||||
#### 存储转发服务器
|
||||
|
||||
存储转发电子邮件服务器通常在 POP 上运行,邮件仅在用户首次访问和下载邮件时才保存在服务器上。这是一个简单直接的设计。
|
||||
|
||||
**优点**
|
||||
|
||||
* 邮件不会在服务器上保留很长时间(直到客户端访问它们),并且服务器不需要对它们进行太多处理。
|
||||
* 客户端设备通常存储已下载的邮件,因此即使没有互联网连接,用户仍然可以访问旧邮件,邮件服务器也因中断而不可用。
|
||||
* 服务器不需要太多存储空间,因为邮件在下载后会被删除。
|
||||
|
||||
**缺点**
|
||||
|
||||
* 由于邮件存储在本地,如果从多个客户端设备访问服务器,则所有邮件的视图不一致。
|
||||
* 用户负责备份和恢复他们的邮件。如果没有备份,如果设备损坏/被盗,邮件将永远丢失。
|
||||
* 诸如邮件搜索/排序之类的功能必须在设备本地完成,这对于包含大量邮件的邮箱来说可能需要大量的计算。在邮件存储在多个设备上的情况下,搜索仅限于当前设备上的邮件,这可能很不方便。
|
||||
|
||||
#### 仅服务器邮箱
|
||||
|
||||
在这种系统中,服务器充当邮件的真实来源,即使客户端下载了邮件,服务器也会保留它们。客户端在下载后不会缓存或持久保存邮件。此类服务器可以在 IMAP 上运行,网络邮件是此类模型的一个常见示例。
|
||||
|
||||
**优点**
|
||||
|
||||
* 所有设备对邮箱都有一致的视图。
|
||||
* 备份可以由电子邮件服务提供商完成,而无需用户进行任何麻烦。
|
||||
* 客户端难以执行或难以执行的功能可以在服务器上执行。像手机这样功能较弱的设备将从中受益。
|
||||
|
||||
**缺点**
|
||||
|
||||
* 需要互联网连接才能查看邮件。
|
||||
* 需要足够的服务器存储空间,因为它是邮件的真实来源。
|
||||
|
||||
#### 具有客户端缓存的服务器邮箱
|
||||
|
||||
混合模型结合了存储转发服务器和仅服务器邮箱的最佳特性,它具有永久的服务器邮箱,客户端缓存/持久保存已下载的邮件。大多数桌面电子邮件客户端都使用此模型,并且是上述模型中最复杂的。
|
||||
|
||||
**优点**
|
||||
|
||||
* 存储转发模型的大部分优点:
|
||||
* 即使离线或电子邮件服务器不可用,也可以访问邮件。
|
||||
* 仅服务器邮箱的优点:
|
||||
* 所有设备对邮箱都有一致的视图。
|
||||
* 备份由电子邮件服务提供商完成。
|
||||
* 利用服务器端功能,如搜索。
|
||||
|
||||
**缺点**
|
||||
|
||||
* 客户端和服务器之间复杂的同步逻辑。
|
||||
* 需要足够的服务器存储空间,因为它仍然是邮件的真实来源。
|
||||
|
||||
*参考:[NinjaMail:高性能集群、分布式电子邮件系统的设计](https://people.eecs.berkeley.edu/~kubitron/papers/ninja/pdf/ninjamail-workshop.pdf)*
|
||||
|
||||
### 本机 HTML 应用程序
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
* 如何构建原生应用程序
|
||||
* 讨论示例 (Electron/Nativefier/Tauri) 及其区别:
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
### 桌面客户端 vs Webmail
|
||||
|
||||
* 分成客户端应用程序与同构核心。
|
||||
* 抽象出数据库层,以便根据运行时环境选择数据库。
|
||||
* 原生桌面应用程序的价值:
|
||||
* 菜单栏
|
||||
* 通知
|
||||
* 不与浏览器冲突的键盘快捷键
|
||||
* 徽章
|
||||
|
||||
### Flux/Redux 架构
|
||||
|
||||
使用 Flux 架构的 reducer 架构/命令查询请求分离。应用程序中许多不同的操作可以被抽象为应用程序范围的命令,并具有多个触发源(例如,UI 元素交互、文件菜单交互、键盘快捷键)。
|
||||
|
||||
* 中央存储。UI 的许多部分依赖于相同的数据存储。
|
||||
|
||||
* 命名空间命令,以便更好地组织和降低冲突的可能性。
|
||||
|
||||
* 轻松实现撤消/重做。
|
||||
|
||||
* [Mailspring 的操作列表](https://github.com/Foundry376/Mailspring/blob/master/app/src/flux/actions.ts)
|
||||
|
||||
### 服务器端渲染 vs 客户端渲染
|
||||
|
||||
* CSR,因为它是一个应用程序。
|
||||
|
||||
### 任务队列
|
||||
|
||||
* 可变操作将立即更新本地数据存储,并将更改排队以同步回您的邮件提供商。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
正在进行中
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
正在进行中
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
* 浏览不同的电子邮件协议:IMAP、SMTP、POP 等。
|
||||
* 与邮件服务器同步
|
||||
* https://www.rfc-editor.org/rfc/rfc4549
|
||||
* 邮件列表
|
||||
* 如何进行线程处理
|
||||
* https://www.jwz.org/doc/threading.html
|
||||
* https://www.rfc-editor.org/rfc/rfc5256
|
||||
* [在电子邮件中识别引用的文本](https://patents.google.com/patent/US7222299)
|
||||
* 搜索电子邮件
|
||||
* 对电子邮件进行排序
|
||||
* 阅读
|
||||
* 阅读电子邮件的 HTML
|
||||
* 注入[浏览器默认样式表](https://github.com/Foundry376/Mailspring/tree/master/app)或使用 webview。
|
||||
* 撰写
|
||||
* 电子邮件地址自动补全
|
||||
* 实现撤消发送功能
|
||||
* 富文本/所见即所得
|
||||
* 附件格式
|
||||
* 性能
|
||||
* 虚拟列表
|
||||
* 延迟加载表情符号选择器等。
|
||||
* a11y
|
||||
* 键盘快捷键
|
||||
* 网络
|
||||
* 同步频率
|
||||
* 使用指数退避进行重试
|
||||
* 处理离线模式
|
||||
* UX
|
||||
* 离线指示器
|
||||
* 滑动姿势
|
||||
* 通知
|
||||
* i18n
|
||||
* RTL
|
||||
* 额外功能
|
||||
* 主题
|
||||
* [拼写检查](https://github.com/Foundry376/Mailspring/app/src/spellchecker.ts)
|
||||
* 保存草稿
|
||||
* 阻止打开跟踪
|
||||
* [打开跟踪的工作原理](https://nylas-mail-lives.gitbooks.io/nylas-mail-docs/content/tracking_and_notifications/226411088-how-does-nylas-perform-open-tracking.html)
|
||||
* 邮件规则
|
||||
* 播放声音
|
||||
|
||||
### 撰写
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
正在进行中
|
||||
|
||||
#### 消息格式
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
正在进行中
|
||||
|
||||
#### 附件
|
||||
|
||||
* 恢复附件。
|
||||
* 异步附件流程。
|
||||
|
||||
### 键盘快捷键
|
||||
|
||||
* 在流行的客户端中实现不同的快捷方式,如 Gmail、Apple Mail、Outlook 等。
|
||||
|
||||
### 撤销/重做
|
||||
|
||||
* 通过操作历史记录和集中式存储轻松实现。
|
||||
* Toasts
|
||||
|
||||
### 性能
|
||||
|
||||
* 对性能的需求不大。
|
||||
* 懒加载是可选的,并且不是优先事项,因为资产是作为应用程序的一部分进行捆绑的。网络请求延迟很短,仅影响 JavaScript 启动和执行时间。
|
||||
|
||||
{/* TODO: ### Right Click Behavior */}
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
* http://www.slate.com/articles/technology/technology/2015/02/email\_overload\_building\_my\_own\_email\_app\_to\_reach\_inbox\_zero.html
|
||||
* https://people.eecs.berkeley.edu/~kubitron/papers/ninja/pdf/ninjamail-workshop.pdf
|
||||
* https://github.com/nylas/nylas-mail
|
||||
* https://en.wikipedia.org/wiki/Internet\_Message\_Access\_Protocol
|
||||
* https://www.rfc-editor.org/rfc/rfc3501
|
||||
* https://github.com/Foundry376/Mailspring
|
||||
* https://web.dev/mailru-cwv/
|
||||
* https://zapier.com/blog/best-email-client-for-mac/
|
||||
* [Email Architecture, Gmail two Step Verification, SMTP POP3 IMAP](https://www.electroniclinic.com/email-architecture-gmail-two-step-verification-smtp-pop3-imap)
|
||||
* [Email Program Classifications](https://web.mit.edu/rhel-doc/5/RHEL-5-manual/Deployment_Guide-en-US/s1-email-types.html)
|
||||
* [How does email work?](https://www.namecheap.com/guru-guides/how-does-email-work)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "f4a498de",
|
||||
"excerpt": "68089a7a"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"4d29047c",
|
||||
"8298b254"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"4d29047c",
|
||||
"8298b254"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: 图片轮播
|
||||
excerpt: 设计一个水平滚动的图片轮播组件
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个图片轮播组件,一次显示一个图片列表,允许用户使用分页按钮浏览它们。
|
||||
|
||||

|
||||
|
|
@ -0,0 +1,202 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"c8d1797f",
|
||||
"17298f3f",
|
||||
"140a9b1",
|
||||
"1c399fc",
|
||||
"960c7a90",
|
||||
"668b3542",
|
||||
"c7d69821",
|
||||
"5e8f2469",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"8374a9c5",
|
||||
"ffbbde5",
|
||||
"91d66590",
|
||||
"eb927ffe",
|
||||
"902a3f0f",
|
||||
"b31ae6f2",
|
||||
"e21472e3",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"bc88706e",
|
||||
"a0ad7468",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"20eadbbb",
|
||||
"5fd599c",
|
||||
"1d59c204",
|
||||
"6374d732",
|
||||
"6bf8df28",
|
||||
"efb9e11a",
|
||||
"7df28362",
|
||||
"e1a159ad",
|
||||
"3f178a80",
|
||||
"22856f17",
|
||||
"d32c5f26",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"ea223105",
|
||||
"bbf33037",
|
||||
"6c089d5",
|
||||
"a88510c6",
|
||||
"f3050ae5",
|
||||
"1b1ad5a8",
|
||||
"dff8455e",
|
||||
"fb30633e",
|
||||
"350ad86c",
|
||||
"dde59a26",
|
||||
"e6ec2dd",
|
||||
"d63b8657",
|
||||
"951db4dd",
|
||||
"b5530ce0",
|
||||
"f6670c85",
|
||||
"d64b0aee",
|
||||
"63b41f2d",
|
||||
"87c4c80c",
|
||||
"ee08798d",
|
||||
"68d073ff",
|
||||
"e2993ff6",
|
||||
"6fd8b6e1",
|
||||
"22ee8d13",
|
||||
"c9f24fb3",
|
||||
"ab34bd00",
|
||||
"8f4c0621",
|
||||
"9846f084",
|
||||
"5a62af83",
|
||||
"a267caa1",
|
||||
"ce1cc801",
|
||||
"fc57793b",
|
||||
"ed2c4eb8",
|
||||
"7ed9dd40",
|
||||
"8192a368",
|
||||
"118aea4e",
|
||||
"3d86a87f",
|
||||
"7e2a89ce",
|
||||
"4b413d4a",
|
||||
"4e7e996a",
|
||||
"55e899c4",
|
||||
"225b0927",
|
||||
"9fe688d6",
|
||||
"df29b84a",
|
||||
"f3b5b8b",
|
||||
"e7bb7378",
|
||||
"3bd22b31",
|
||||
"8b864b70",
|
||||
"88d75d1f",
|
||||
"5ebbb865",
|
||||
"c35f60dc",
|
||||
"6afeeca9",
|
||||
"6af6dd32",
|
||||
"2a7816d0",
|
||||
"97ed4a3a",
|
||||
"5237285a",
|
||||
"62fd75b6",
|
||||
"4b795c9c"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"c8d1797f",
|
||||
"17298f3f",
|
||||
"140a9b1",
|
||||
"1c399fc",
|
||||
"960c7a90",
|
||||
"668b3542",
|
||||
"c7d69821",
|
||||
"5e8f2469",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"8374a9c5",
|
||||
"ffbbde5",
|
||||
"91d66590",
|
||||
"eb927ffe",
|
||||
"902a3f0f",
|
||||
"b31ae6f2",
|
||||
"e21472e3",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"bc88706e",
|
||||
"a0ad7468",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"20eadbbb",
|
||||
"5fd599c",
|
||||
"1d59c204",
|
||||
"6374d732",
|
||||
"6bf8df28",
|
||||
"efb9e11a",
|
||||
"7df28362",
|
||||
"e1a159ad",
|
||||
"3f178a80",
|
||||
"22856f17",
|
||||
"d32c5f26",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"ea223105",
|
||||
"bbf33037",
|
||||
"6c089d5",
|
||||
"a88510c6",
|
||||
"f3050ae5",
|
||||
"1b1ad5a8",
|
||||
"dff8455e",
|
||||
"fb30633e",
|
||||
"350ad86c",
|
||||
"dde59a26",
|
||||
"e6ec2dd",
|
||||
"d63b8657",
|
||||
"951db4dd",
|
||||
"b5530ce0",
|
||||
"f6670c85",
|
||||
"d64b0aee",
|
||||
"63b41f2d",
|
||||
"87c4c80c",
|
||||
"ee08798d",
|
||||
"68d073ff",
|
||||
"e2993ff6",
|
||||
"6fd8b6e1",
|
||||
"22ee8d13",
|
||||
"c9f24fb3",
|
||||
"ab34bd00",
|
||||
"8f4c0621",
|
||||
"9846f084",
|
||||
"5a62af83",
|
||||
"a267caa1",
|
||||
"ce1cc801",
|
||||
"fc57793b",
|
||||
"ed2c4eb8",
|
||||
"7ed9dd40",
|
||||
"8192a368",
|
||||
"118aea4e",
|
||||
"3d86a87f",
|
||||
"7e2a89ce",
|
||||
"4b413d4a",
|
||||
"4e7e996a",
|
||||
"55e899c4",
|
||||
"225b0927",
|
||||
"9fe688d6",
|
||||
"df29b84a",
|
||||
"f3b5b8b",
|
||||
"e7bb7378",
|
||||
"3bd22b31",
|
||||
"8b864b70",
|
||||
"88d75d1f",
|
||||
"5ebbb865",
|
||||
"c35f60dc",
|
||||
"6afeeca9",
|
||||
"6af6dd32",
|
||||
"2a7816d0",
|
||||
"97ed4a3a",
|
||||
"5237285a",
|
||||
"62fd75b6",
|
||||
"4b795c9c"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
## 需求探索
|
||||
|
||||
### 如何指定图片?
|
||||
|
||||
它将是组件上的一个配置选项,并且必须在初始化组件之前指定图片列表。
|
||||
|
||||
### 组件应该支持哪些设备?
|
||||
|
||||
桌面、平板电脑和移动设备。
|
||||
|
||||
### 当用户位于图片列表的开头/结尾时,分页按钮将如何表现?
|
||||
|
||||
它应该无限循环。
|
||||
|
||||
### 在图像之间切换时是否会有动画?
|
||||
|
||||
是的,图像应该使用水平转换进行动画处理。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
由于此组件不需要任何服务器数据,因此我们的架构将仅由客户端组件组成。
|
||||
|
||||

|
||||
|
||||
### 组件职责
|
||||
|
||||
* **ViewModel + Model**:组件的大脑,保存组件的配置和状态,协调组件之间的事件,并通知“Image”组件要渲染哪个图像。
|
||||
* **Image**:显示当前选择的图像。
|
||||
* **Prev/Next Buttons**:告诉“ViewModel”根据单击的按钮更改为上一个/下一个图像。
|
||||
* **Progress Dots**:告诉“ViewModel”在单击/选择相应的点/页面时显示哪个图像。
|
||||
|
||||
由于这是一个小组件,因此无需在此组件中分离“Model”和“ViewModel”。
|
||||
|
||||
### Flux 架构
|
||||
|
||||
建议使用类似 [Flux](https://facebook.github.io/flux/)/[Redux](https://redux.js.org/)/[Reducer](https://beta.reactjs.org/learn/scaling-up-with-reducer-and-context) 的架构,以将操作源从操作逻辑/实现中抽象出来。 一些操作可以通过与各种 UI 元素交互、由计时器定期触发或按键来触发。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
只有“ViewModel”将保存任何状态和数据,其他组件是视图的一部分,不会保存数据。 它可以包含以下字段:
|
||||
|
||||
* 配置
|
||||
* 图像列表,包括图像 URL 和 `alt` 值(如果提供)。
|
||||
* 切换时间。
|
||||
* 尺寸:图像的高度和宽度。
|
||||
* 状态
|
||||
* 当前图像的索引。此值可以通过交互元素(上一个/下一个按钮、进度点)进行修改。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
由于我们在这里讨论的是设计 UI 组件,因此 API 将侧重于组件的外部 API:提供哪些配置选项,以便开发人员可以以自定义方式使用该组件。
|
||||
|
||||
### 基本 API
|
||||
|
||||
* **图像列表**:要在轮播中显示的一组图像 URL 以及任何关联的元数据(可选,但最好有)。
|
||||
* **切换时间 (ms)**:图像切换期间的切换动画的持续时间。
|
||||
* **高度 (px)**:图像的高度。
|
||||
* **宽度 (px)**:图像的宽度。
|
||||
|
||||
以下是 React 中定义的 `ImageCarousel` 组件的示例:
|
||||
|
||||
```jsx
|
||||
<ImageCarousel
|
||||
images={[
|
||||
{ src: 'https://example.com/images/foo.jpg', alt: 'A foo' },
|
||||
{ src: 'https://example.com/images/bar.jpg', alt: 'A bar' },
|
||||
/* 更多图像(如果需要)。 */
|
||||
]}
|
||||
transitionDuration={300}
|
||||
height={500}
|
||||
width={800}
|
||||
/>
|
||||
```
|
||||
|
||||
### 高级 API
|
||||
|
||||
如果时间允许,这些是非必要的 API,但值得讨论。
|
||||
|
||||
* **自动播放**:轮播是否会在一段时间后自动进行到下一张图像。
|
||||
* 将需要一个计时器状态值来持续增加图像。
|
||||
* 如果用户手动更改了当前图像(通过上一个/下一个按钮或进度点),则应取消/重置计时器。
|
||||
* **延迟**:切换之间的延迟。仅在轮播处于自动播放模式时才需要。
|
||||
* **事件监听器**:将事件监听器添加到组件的按钮会很有用,以便开发人员可以实现自己的额外功能(例如,记录用户交互)
|
||||
* `onLoad`:当第一张图像加载完成并在轮播中显示时。
|
||||
* `onPageSelect`:选择页面时。
|
||||
* `onNextClick`:单击下一张图像按钮时。
|
||||
* `onPrevClick`:单击上一张图像按钮时。
|
||||
* **主题**:请参阅 [前端面试指南的 UI 组件 API 设计原则部分](/front-end-interview-guidebook/user-interface-components-api-design-principles)。
|
||||
* **循环**:启用循环行为,在显示最后一张图像时单击“下一个”按钮将返回到第一张图像,而在显示第一张图像时单击“上一个”按钮将显示最后一张图像。
|
||||
|
||||
### 内部 API
|
||||
|
||||
| API | 来源 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `prevImage()` | 上一个按钮 / 键盘事件 | 显示上一张图像 |
|
||||
| `nextImage()` | 下一个按钮 / 键盘事件 / 计时器(如果自动播放) | 显示下一张图像 |
|
||||
| `showImage(index)` | 进度点 | 跳到特定图像 |
|
||||
|
||||
* 这些行为封装在 API 中,因为它们可以从多个地方(UI 元素、计时器)调用,并且可以包含大量逻辑,具体取决于是否启用了循环行为。
|
||||
* `prevImage()` 和 `nextImage()` 可以在内部调用 `showImage(index)`。
|
||||
* 如果使用 Flux/Redux 架构,这些内部 API 可以实现为 Flux/Redux 操作。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### 布局实现
|
||||
|
||||
#### 图像
|
||||
|
||||
实现该布局的一个简单方法是使用 `display: flex` 使图像在水平行中呈现,并通过编程方式更改水平滚动偏移量以显示各种图像。
|
||||
|
||||
您可以在白板上绘制这样的图表来说明布局:
|
||||
|
||||

|
||||
|
||||
带有黑色边框的框指示当前可见的窗口。
|
||||
|
||||
**示例代码**
|
||||
|
||||
```html
|
||||
<div class="carousel-images">
|
||||
<img class="carousel-image" alt="..." src="..." />
|
||||
<img class="carousel-image" alt="..." src="..." />
|
||||
<img class="carousel-image" alt="..." src="..." />
|
||||
<!-- More images -->
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.carousel-images {
|
||||
display: flex;
|
||||
height: 400px;
|
||||
width: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-image {
|
||||
height: 400px;
|
||||
width: 600px;
|
||||
}
|
||||
```
|
||||
|
||||
为了平滑地滚动到特定图像,我们可以这样做:
|
||||
|
||||
```js
|
||||
document.querySelector('.carousel-images').scrollTo({
|
||||
left: selectedIndex * 600,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
```
|
||||
|
||||
#### 调整图像大小
|
||||
|
||||
上面的布局假设所有图像的大小都相同。但是,用户不太可能提供完全相同大小的图像。
|
||||
|
||||
以下是一些我们可以解决此问题的方法:
|
||||
|
||||
**CSS `background-size`**:这需要在 HTML 中进行更改,以使用 CSS `background`/`background-image` 而不是 `<img src="...">` 呈现图像。 这样做的好处是我们可以使用具有这两种模式的 `background-size` 属性:
|
||||
|
||||
* `contain`:在不裁剪或拉伸图像的情况下,将图像缩放到其容器内的最大尺寸。 如果容器大于图像,这将导致图像平铺,除非将 `background-repeat` 属性设置为 `no-repeat`。
|
||||
* `cover`:将图像(同时保留其比例)缩放到填充容器的最小可能尺寸(即:其高度和宽度完全覆盖容器),不留任何空白。 如果背景的比例与元素不同,则图像会被垂直或水平裁剪。
|
||||
|
||||
*来源:[background-size - CSS: Cascading Style Sheets | MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/background-size)*
|
||||
|
||||
这两种优势都有其优点,使用哪种优势取决于提供的图像。 一种方法是允许开发人员自定义是为所有图像使用 `contain` 还是 `cover`,甚至允许自定义单个图像的此设置。
|
||||
|
||||
**CSS `object-fit`**:CSS `object-fit` 的功能类似于 `background-size` 对 `background-image` 的作用,但适用于 `<img>` 和 `<video>` HTML 标签。 它也有 `contain` 和 `cover` 属性,其工作方式与 `background-size` 的版本相同。
|
||||
|
||||
这种方式是首选,因为它比使用带有 `background-image` 的 `<div>` 更具语义,而不是使用 `<img>` 标签。
|
||||
|
||||
#### 垂直居中按钮
|
||||
|
||||
要垂直居中按钮,我们可以在按钮上使用 `position: absolute` 以及一些 `transform: translateY(-50%)` 将其向上移动其高度的一半。
|
||||
|
||||
```html
|
||||
<div class="carousel-image-container">
|
||||
<div class="carousel-images">
|
||||
<img class="carousel-image" alt="..." src="..." />
|
||||
<img class="carousel-image" alt="..." src="..." />
|
||||
<!-- More images -->
|
||||
</div>
|
||||
<button class="carousel-button carousel-button-prev">...</button>
|
||||
<button class="carousel-button carousel-button-next">...</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.carousel-image-container {
|
||||
height: 400px;
|
||||
width: 600px;
|
||||
position: relative; /* 这样 position: absolute 将相对于此元素。 */
|
||||
}
|
||||
|
||||
.carousel-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%); /* 将按钮向上移动其高度的一半。 */
|
||||
}
|
||||
|
||||
.carousel-button-prev {
|
||||
left: 30px;
|
||||
}
|
||||
|
||||
.carousel-button-next {
|
||||
right: 30px;
|
||||
}
|
||||
```
|
||||
|
||||
### 用户体验
|
||||
|
||||
* **滚动捕捉**:使用 [CSS 滚动捕捉](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type) 并显示滚动条,以便用户仍然可以使用原生滚动条滚动浏览图像,但图像将“捕捉”得很好,并且滚动位置将很好地对齐以在滚动停止后显示完整的图像。 移动用户希望能够滑动浏览图像,CSS 滚动捕捉可帮助您在移动设备上轻松实现此目的。
|
||||
* **交互元素应该足够大**。 Prev/Next 按钮的高度和宽度应至少为 44px,以便于在移动设备上点击。 一个技巧是将按钮的点击区域增加到最左侧/最右侧的整个部分。 对于精确的交互,点在移动设备上可能太小,可以隐藏或设置为非交互式。
|
||||
* **重新定位 Prev/Next 按钮**:将 Prev/Next 按钮彼此靠近更方便。 这与示例设计相反,但可以加快导航速度,因为按钮之间的距离最小化。
|
||||
|
||||
### 性能
|
||||
|
||||
#### 延迟加载未在屏幕上的图像
|
||||
|
||||
轮播图上的大多数图像从未向用户显示,尤其是在后面的图像。如果未显示所有图像,加载所有图像将浪费带宽。
|
||||
|
||||
因此,只有当图像在屏幕上(或即将显示)时才能加载它们。这可以使用 JavaScript 或仅通过 HTML 实现。纯 HTML 方法涉及使用 [`<img loading="lazy">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading) 属性,该属性会延迟加载当前未在可见视口中的图像。
|
||||
|
||||
#### 预加载图像
|
||||
|
||||
如果需要对图像加载进行细粒度控制,可以使用 JavaScript。为了帮助最大限度地减少需要显示但尚未完成加载的图像的情况,该组件可以预先加载下一张图像,使用以下代码:
|
||||
|
||||
```js
|
||||
const preloadImage = new Image();
|
||||
preloadImage.src = url;
|
||||
```
|
||||
|
||||
[Airbnb 通过结合使用延迟加载和预加载](https://medium.com/airbnb-engineering/building-a-faster-web-experience-with-the-posttask-scheduler-276b83454e91)行为优化了其房间列表中的图片轮播体验:
|
||||
|
||||
1. 最初,仅加载第一张图像(其余图像将延迟加载)。
|
||||
2. 当用户显示出查看更多图像的可能意图时,会预加载第二张图像:
|
||||
* 鼠标悬停在图片轮播图上。
|
||||
* 通过制表符键将焦点放在“下一步”按钮上。
|
||||
* 图片轮播图进入视图(在移动设备上)。
|
||||
3. 如果用户确实查看了第二张图像(这表明有很高的浏览更多图像的意图),则会预加载接下来的三张图像(第 3 到 5 张)。
|
||||
4. 当用户再次单击“下一步”以浏览更多图像时,会预加载 (n + 3)<sup>th</sup> 张图像。
|
||||
|
||||
<figure>
|
||||
<img alt="Airbnb Image Carousel Lazy Loading" className="mx-auto w-full max-w-5xl" loading="lazy" src="/img/questions/travel-booking-airbnb/airbnb-image-loading.gif" />
|
||||
|
||||
<figcaption>Airbnb image carousel lazy loading example on mobile</figcaption>
|
||||
</figure>
|
||||
|
||||
#### 特定于设备的图像
|
||||
|
||||
在移动设备上加载高分辨率图像将是一种浪费,因为屏幕尺寸太小,无法显示细节。要添加的一个好功能是允许用户提供不同大小的图像以在不同设备上显示。这可以使用 JavaScript、`<img>` 标签上的 `srcset` 属性或新的 `<picture>` 标签来实现。
|
||||
|
||||
{/* TODO 比较这些方法 */}
|
||||
|
||||
#### 虚拟化列表
|
||||
|
||||
如果图像太多,我们可以使用[虚拟化列表](https://www.patterns.dev/posts/virtual-lists/)仅渲染 DOM 中可见的图像。
|
||||
|
||||
{/* TODO: 图像的异步解码 */}
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
i18n 与此问题关系不大,但用户应该能够为页面当前语言的“上一个/下一个”按钮自定义 `aria-label` 字符串。这些字符串可以指定为配置选项。
|
||||
|
||||
### 可访问性 (a11y)
|
||||
|
||||
由于构建图片轮播图需要付出很多努力,因此它们以可访问性差而闻名。因此,在没有期望投入大量时间来实现高质量组件的情况下,您可能不应该构建自己的自定义图片轮播图。构建可访问的图片轮播图时需要注意的一些事项。
|
||||
|
||||
#### 移动端友好性
|
||||
|
||||
* 交互元素应足够大,适合移动设备(至少 44px x 44px)。
|
||||
* 启用滑动来滚动浏览图片(使用 `overflow-x: scroll` + [CSS scroll snap](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type) 实现已是如此)。
|
||||
* 进度点应间隔更远或设置为非交互式。
|
||||
|
||||
#### 屏幕阅读器
|
||||
|
||||
* `<img>` 标签应具有指定的有意义的 `alt` 描述或使用空字符串。
|
||||
* 为 Prev/Next 按钮添加 `aria-label`,因为它们内部没有可见的标签。
|
||||
|
||||
#### 键盘支持
|
||||
|
||||
* 尽可能为 Prev/Next 按钮使用 `<button>` HTML 标签,以便按钮可聚焦。
|
||||
* 添加 `<div role="region" aria-label="Image Carousel" tabindex="0">` 使轮播图可聚焦,并附加 Left/Right 键盘处理程序,以允许使用键盘滚动浏览图片。
|
||||
|
||||
***
|
||||
|
||||
## 更新日志
|
||||
|
||||
* 2023/01/22
|
||||
* 提及 Flux 架构。
|
||||
* 扩展主题部分。
|
||||
* 添加 Airbnb 图片轮播示例。
|
||||
|
||||
## 参考
|
||||
|
||||
* [创建可访问的图片轮播图](https://www.aleksandrhovhannisyan.com/blog/image-carousel-tutorial/)
|
||||
* [设计完美的轮播图 UX](https://www.smashingmagazine.com/2022/04/designing-better-carousel-ux/)
|
||||
* [内容滑块](https://inclusive-components.design/a-content-slider/)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "141d4960",
|
||||
"excerpt": "f570db60"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"889a30c8",
|
||||
"689f6584",
|
||||
"9ec6d64c",
|
||||
"94dc73c5"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"889a30c8",
|
||||
"689f6584",
|
||||
"9ec6d64c",
|
||||
"94dc73c5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
title: 模态对话框
|
||||
excerpt: 设计一个模态/对话框组件,该组件显示一个覆盖页面内容的窗口
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个模态/对话框组件,该组件在覆盖页面的窗口中显示内容。
|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* [Modal · Bootstrap v5.3](https://getbootstrap.com/docs/5.3/components/modal)
|
||||
* [React Modal component - Material UI](https://mui.com/material-ui/react-modal/)
|
||||
* [Dialog — Radix UI](https://www.radix-ui.com/docs/primitives/components/dialog)
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"4a308bd4",
|
||||
"e7b35b25",
|
||||
"c4e8b38",
|
||||
"8c8945d7",
|
||||
"24febbf2",
|
||||
"f7fe2ea",
|
||||
"2a7816d0",
|
||||
"1044a16b",
|
||||
"3cce7975",
|
||||
"4d446fb7",
|
||||
"39f7cd26",
|
||||
"ded65a3e",
|
||||
"ad9379d1",
|
||||
"eab92f70",
|
||||
"f2ddcac6",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"8373fafa",
|
||||
"d5ca8c24",
|
||||
"c57f9198",
|
||||
"743988f5",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"d73d335",
|
||||
"36f975b",
|
||||
"b982cc5b",
|
||||
"1a2f70e9",
|
||||
"bcff9474",
|
||||
"e4f481c1",
|
||||
"f45c4591",
|
||||
"932f9005",
|
||||
"f45c4591",
|
||||
"a5ebbcda",
|
||||
"f45c4591",
|
||||
"e168a7a0",
|
||||
"187fec08",
|
||||
"497153b2",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"65aca1ee",
|
||||
"1d91621f",
|
||||
"59dd3381",
|
||||
"3497a062",
|
||||
"126fece8",
|
||||
"445758ce",
|
||||
"452a0924",
|
||||
"f62c0050",
|
||||
"ea660353",
|
||||
"5f20bbe5",
|
||||
"5b1b1ea0",
|
||||
"59336d49",
|
||||
"778a0878",
|
||||
"5eff5610",
|
||||
"b6cd53a1",
|
||||
"1fbe24f",
|
||||
"ad8a3821",
|
||||
"23f2adb0",
|
||||
"78a61d86",
|
||||
"aea5c8ad",
|
||||
"ac71feb1",
|
||||
"e52cd970",
|
||||
"fd2c9177",
|
||||
"45fb993c",
|
||||
"1c4c4734",
|
||||
"e7bb7378",
|
||||
"b50ffa23",
|
||||
"3acd51f4",
|
||||
"37e02f58",
|
||||
"642bb8a9",
|
||||
"ee66ed19",
|
||||
"77b48656",
|
||||
"5e6b920a",
|
||||
"6c35e628",
|
||||
"f3e16242",
|
||||
"4edf8756",
|
||||
"557e072f",
|
||||
"cb08c4de",
|
||||
"e663ea4f",
|
||||
"7f022d8e",
|
||||
"9a899aa6",
|
||||
"f36a66c3",
|
||||
"24f1d988",
|
||||
"4a5d5de0",
|
||||
"240cb8fc",
|
||||
"7d1c569e",
|
||||
"e859a013",
|
||||
"959320f2",
|
||||
"acc3fafb",
|
||||
"65de48e9",
|
||||
"2c9661f9",
|
||||
"493b8f0c",
|
||||
"57cfe721",
|
||||
"d9363f00",
|
||||
"5f3f738e",
|
||||
"f359ea19",
|
||||
"df29b84a",
|
||||
"932b977a",
|
||||
"599580dd",
|
||||
"3ce1b3ef",
|
||||
"af9ba3ce",
|
||||
"bf4d69fe",
|
||||
"f79207f4",
|
||||
"4ec86f05",
|
||||
"6101654d",
|
||||
"edb5cf26",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"be92e4d8"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"4a308bd4",
|
||||
"e7b35b25",
|
||||
"c4e8b38",
|
||||
"8c8945d7",
|
||||
"24febbf2",
|
||||
"f7fe2ea",
|
||||
"2a7816d0",
|
||||
"1044a16b",
|
||||
"3cce7975",
|
||||
"4d446fb7",
|
||||
"39f7cd26",
|
||||
"ded65a3e",
|
||||
"ad9379d1",
|
||||
"eab92f70",
|
||||
"f2ddcac6",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"8373fafa",
|
||||
"d5ca8c24",
|
||||
"c57f9198",
|
||||
"743988f5",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"d73d335",
|
||||
"36f975b",
|
||||
"b982cc5b",
|
||||
"1a2f70e9",
|
||||
"bcff9474",
|
||||
"e4f481c1",
|
||||
"f45c4591",
|
||||
"932f9005",
|
||||
"f45c4591",
|
||||
"a5ebbcda",
|
||||
"f45c4591",
|
||||
"e168a7a0",
|
||||
"187fec08",
|
||||
"497153b2",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"65aca1ee",
|
||||
"1d91621f",
|
||||
"59dd3381",
|
||||
"3497a062",
|
||||
"126fece8",
|
||||
"445758ce",
|
||||
"452a0924",
|
||||
"f62c0050",
|
||||
"ea660353",
|
||||
"5f20bbe5",
|
||||
"5b1b1ea0",
|
||||
"59336d49",
|
||||
"778a0878",
|
||||
"5eff5610",
|
||||
"b6cd53a1",
|
||||
"1fbe24f",
|
||||
"ad8a3821",
|
||||
"23f2adb0",
|
||||
"78a61d86",
|
||||
"aea5c8ad",
|
||||
"ac71feb1",
|
||||
"e52cd970",
|
||||
"fd2c9177",
|
||||
"45fb993c",
|
||||
"1c4c4734",
|
||||
"e7bb7378",
|
||||
"b50ffa23",
|
||||
"3acd51f4",
|
||||
"37e02f58",
|
||||
"642bb8a9",
|
||||
"ee66ed19",
|
||||
"77b48656",
|
||||
"5e6b920a",
|
||||
"6c35e628",
|
||||
"f3e16242",
|
||||
"4edf8756",
|
||||
"557e072f",
|
||||
"cb08c4de",
|
||||
"e663ea4f",
|
||||
"7f022d8e",
|
||||
"9a899aa6",
|
||||
"f36a66c3",
|
||||
"24f1d988",
|
||||
"4a5d5de0",
|
||||
"240cb8fc",
|
||||
"7d1c569e",
|
||||
"e859a013",
|
||||
"959320f2",
|
||||
"acc3fafb",
|
||||
"65de48e9",
|
||||
"2c9661f9",
|
||||
"493b8f0c",
|
||||
"57cfe721",
|
||||
"d9363f00",
|
||||
"5f3f738e",
|
||||
"f359ea19",
|
||||
"df29b84a",
|
||||
"932b977a",
|
||||
"599580dd",
|
||||
"3ce1b3ef",
|
||||
"af9ba3ce",
|
||||
"bf4d69fe",
|
||||
"f79207f4",
|
||||
"4ec86f05",
|
||||
"6101654d",
|
||||
"edb5cf26",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"be92e4d8"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
## 需求探索
|
||||
|
||||
### 模态框的组成部分是什么?
|
||||
|
||||
这取决于你。在基本层面上,它应该允许自定义内容。是否添加对关闭按钮、标题、页脚操作的内置支持将取决于你。
|
||||
|
||||
### 用户在自定义设计方面有多大的灵活性?
|
||||
|
||||
用户应该能够自定义模态框元素的颜色、字体、填充等,以匹配他们的品牌。
|
||||
|
||||
### 此组件将在哪些设备上使用?
|
||||
|
||||
所有设备 — 手机、平板电脑、桌面。
|
||||
|
||||
***
|
||||
|
||||
{/* ## TODO: Background, discuss when modals should be used. */}
|
||||
|
||||
## 架构/高层设计
|
||||
|
||||
模态框,就像许多显示内容的组件一样,有一个触发元素和内容元素。模态框可以通过用户操作或后台操作打开,因此我们应该将触发源与模态框内容分离。
|
||||
|
||||
仅包含内容的简单模态框没有复杂的架构。但是,许多来自 UI 库的模态框有三个不同的部分:标题、正文、页脚。
|
||||
|
||||
在 React 中使用模态框组件的示例,省略了事件处理程序。
|
||||
|
||||
```jsx
|
||||
<Modal>
|
||||
<Modal.Header>我的模态框标题</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>模态框正文文本在这里。</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<button>关闭</button>
|
||||
<button>确认</button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
| 组件 | 角色 |
|
||||
| --- | --- |
|
||||
| 模态框根 (`Modal`) | 根组件,协调内部组件之间的事件。 |
|
||||
| 模态框覆盖层 | 渲染背景覆盖层的组件,通常会使页面内容变暗。 |
|
||||
| 模态框标题 (`Modal.Header`) | 模态框的顶部部分,包含标题和关闭按钮。 |
|
||||
| 模态框正文 (`Modal.Body`) | 模态框的主要内容。 |
|
||||
| 模态框页脚 (`Modal.Footer`) | 模态框的可选底部部分,通常包含关闭/提交按钮。 |
|
||||
|
||||
在 React 中,组件可以使用 context 或 props 与其父组件通信。我们选择在这里使用 context,因为我们在这里使用组合模型,传递 props 并不方便。`<Modal>` 应该包含一个 context 提供程序,该提供程序将配置选项(`<Modal>` 的 `props`)提供给其所有子组件,以防它们需要它。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
请注意,对于设计组件,先设计接口/API 或同时设计数据模型和 API 可能会有意义。这取决于手头的组件。随意在两个部分之间跳转。
|
||||
|
||||
模态框组件不需要太多状态。我们将把模态框构建为一个受控组件,这是库通常采用的方法。因此,打开/关闭状态在组件外部管理。
|
||||
|
||||
| 状态 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `previousFocusElement` | `HTMLElement` | 在显示模态框之前获得焦点的 DOM 元素。在 [焦点管理](#focus-management) 部分阅读更多关于为什么需要它的信息。 |
|
||||
|
||||
有关配置选项,请参见下文,这些选项也是数据模型的一部分。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
### 常规属性
|
||||
|
||||
这些属性适用于大多数组件。
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `children` | `React.Node` | 组件的子元素。如果使用 TypeScript/Flow,您可以强制使用特定的组件作为 `children`。 |
|
||||
| `as` | `string \| Component` | 如果需要自定义底层 DOM 元素/组件。 |
|
||||
| `className` | `string` | 要添加到组件的类名,以备需要进一步的视觉定制。可能需要也可能不需要,这取决于主题方法。 |
|
||||
|
||||
### `Modal`
|
||||
|
||||
| 属性 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `isOpen` | `boolean` | 模态框是否打开或关闭。 |
|
||||
| `onClose` | `Function` | 当模态框关闭时触发的回调,可能来自按下关闭按钮或按下“Escape”键”。 |
|
||||
| `maxHeight` | `number \| undefined` | 模态框的最大高度。应该有一个合理的默认值,大约是视口高度的 80%。 |
|
||||
| `width` | `number \| undefined` | 模态框的宽度。应该有一个合理的默认值,为 500-600px。 |
|
||||
|
||||
### `Modal.Header`
|
||||
|
||||
基本版本不需要除 `children` 之外的属性。
|
||||
|
||||
### `Modal.Body`
|
||||
|
||||
基本版本不需要除 `children` 之外的属性。
|
||||
|
||||
### `Modal.Footer`
|
||||
|
||||
基本版本不需要除 `children` 之外的属性。
|
||||
|
||||
### 自定义外观
|
||||
|
||||
设计用于自定义 UI 组件的良好 API 可以在 [前端面试指南的 UI 组件 API 设计原则部分](/front-end-interview-guidebook/user-interface-components-api-design-principles)中找到。
|
||||
|
||||
模态框的大部分内容(在标题/正文/页脚中)将由用户提供,因此模态框组件不需要提供太多默认样式。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### 渲染
|
||||
|
||||
#### 突破 DOM 层次结构
|
||||
|
||||
由于模态框显示在页面上方,并且不遵循页面元素的正常流程,因此渲染模态框比看起来更棘手。将模态框渲染在父 DOM 层次结构之外非常重要,因为如果父元素包含剪裁其内容的样式,则模态框内容可能无法完全可见。以下是 [React 文档](https://beta.reactjs.org/reference/react-dom/createPortal#rendering-a-modal-dialog-with-a-portal) 中的一个示例,演示了这个问题。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/wnr51p?fontsize=14&hidenavigation=1&theme=dark&module=/App.js,/NoPortalExample.js,/PortalExample.js,/ModalContent.js,/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Modal Clipping Example"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
在 React 中,使用 [React Portals](https://beta.reactjs.org/reference/react-dom/createPortal) 可以实现在父组件的 DOM 层次结构之外进行渲染。Portals 的常见用例包括工具提示、下拉菜单、弹出框。
|
||||
|
||||
#### 遮罩层
|
||||
|
||||
为了帮助用户专注于模态框中的内容,通常会有一个遮罩层/背景来使页面的内容变暗。为了渲染一个覆盖整个页面的元素,我们可以使用以下 CSS:
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
/* 黑色,带有一些不透明度。 */
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
```
|
||||
|
||||
#### 居中模态框
|
||||
|
||||
为了在模态框遮罩层内居中模态框内容,我们可以向 `.modal-overlay` 添加以下样式:
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
/* 省略原始样式。 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
```
|
||||
|
||||
这将与以下 HTML 结构一起使用。
|
||||
|
||||
```html
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-contents">...</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
如果需要垂直居中内容,可以将 `align-items: center` 添加到 `.modal-overlay`。
|
||||
|
||||
这是一个带有遮罩层和可选垂直居中模式的模态框的基本示例:
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/t1oldf?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Modal Example"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
#### 最大高度
|
||||
|
||||
由于模态框可以包含大量内容,我们可以为模态框设置一个默认的最大高度,以便多余的条目可以在 `Modal.Body` 中滚动。此高度也可以通过指定 `maxHeight` 属性来定制。
|
||||
|
||||
#### 滚动锁定
|
||||
|
||||
当模态框显示时,模态框内容处于焦点。为了防止用户滚动背景内容,页面应该锁定页面级别的滚动。一种方法是向 `<body>` 添加 `overflow: hidden`。
|
||||
|
||||
#### 在 HTML 或 JavaScript 中渲染
|
||||
|
||||
模态框可以是:
|
||||
|
||||
1. 像 [Bootstrap 的模态框](https://getbootstrap.com/docs/5.3/components/modal/) 一样渲染到 HTML 中。模态框最初通过 `display: none` / `opacity: 0` / `hidden` 属性从视图中隐藏,当要显示模态框时,这些样式会被切换。
|
||||
2. 在激活模态框触发按钮后,通过 JavaScript 动态渲染。
|
||||
|
||||
首先在 HTML 中渲染的优点是更好的运行时性能,因为显示模态框所需的 DOM 操作更少。缺点是 HTML 可能会不必要地膨胀,特别是如果模态框根本没有显示。由于模态框内容通常包含次要信息,因此它们不应影响 SEO,也不需要进行服务器端渲染。在 HTML 中预先渲染模态框的好处相对较小。
|
||||
|
||||
{/* ### TODO `z-index` */}
|
||||
|
||||
### 可访问性 (a11y)
|
||||
|
||||
#### 鼠标交互
|
||||
|
||||
通常,点击模态框外部(在覆盖层上)将关闭模态框。 我们必须确保模态框内的点击不会关闭模态框。
|
||||
|
||||
```js
|
||||
function clickListener(event) {
|
||||
// 如果点击的元素是模态框内容的后代,则不执行任何操作。
|
||||
if ($modalContentsElement.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', clickListener);
|
||||
document.addEventListener('touchstart', clickListener);
|
||||
```
|
||||
|
||||
请记住在模态框关闭时删除 `clickListener`。
|
||||
|
||||
这是一个 React 示例:
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/74fyqc?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Modal Dismiss On Click Outside"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
#### 焦点管理
|
||||
|
||||
实现模态框最复杂的部分可能是焦点管理。 模态框内的内容应被视为一个单独的文档; 使用 <kbd>Tab</kbd> 键仅在对话框内循环切换可聚焦元素,并且只要显示模态框,焦点就永远不能位于组件外部的元素上。 这种行为/现象被称为“焦点捕获”。
|
||||
|
||||
当模态框打开时,焦点会移动到模态框内的元素。 通常,焦点设置在第一个可聚焦元素上,但 [对话框(模态框)模式](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) 中提到了一些例外情况。
|
||||
|
||||
当模态框关闭时,焦点会返回到打开模态框的元素(除非该元素不再存在,然后焦点会转移到另一个合理的元素)。
|
||||
|
||||
**如何实现模态框的焦点管理**:
|
||||
|
||||
1. 首次打开模态框时,保留对在模态框状态中打开模态框的元素的引用。
|
||||
2. 聚焦模态框内的元素。
|
||||
3. 添加 <kbd>Tab</kbd> 键的 `keydown` 侦听器,其中包含以下逻辑:
|
||||
1. 当按下 <kbd>Tab</kbd> 键时,通过检查是否也按下了 <kbd>Shift</kbd> 键(通过 `KeyboardEvent` 上的 `shiftKey` 值)来确定焦点是否应转移到下一个或上一个可切换元素。
|
||||
2. 查找模态框内的所有可切换元素。
|
||||
3. 从当前聚焦的元素中,找到下一个/上一个可切换元素。
|
||||
4. 聚焦该新元素。
|
||||
4. 当模态框关闭时,隐藏模态框并将焦点移动到打开模态框的元素。
|
||||
|
||||
在实践中,可以通过 [focus-trap](https://focus-trap.github.io/focus-trap/) 库来完成焦点捕获。 如果使用 React,则使用 [`react-focus-lock`](https://github.com/theKashey/react-focus-lock) 库,[Reach UI 的 Dialog 组件](https://reach.tech/dialog) 使用该库。
|
||||
|
||||
#### 键盘交互
|
||||
|
||||
以下内容摘自 [对话框(模态框)模式](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/):
|
||||
|
||||
| 键 | 描述 |
|
||||
| --- | --- |
|
||||
| <kbd>Tab</kbd> | 将焦点移动到模态框内的下一个可切换元素。 如果焦点位于模态框内的最后一个可切换元素上,则将焦点移动到模态框内的第一个可切换元素。 |
|
||||
| <kbd>Shift</kbd> + <kbd>Tab</kbd> | 将焦点移动到模态框内的上一个可切换元素。 如果焦点位于模态框内的第一个可切换元素上,则将焦点移动到模态框内的最后一个可切换元素。 |
|
||||
| <kbd>Esc</kbd> | 关闭模态框。 |
|
||||
|
||||
焦点捕获对于所需的 <kbd>Tab</kbd> 行为至关重要,否则焦点将“泄漏”出模态框:
|
||||
|
||||
#### WAI-ARIA 角色、状态和属性
|
||||
|
||||
以下内容摘自 [对话框(模态框)模式](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)。
|
||||
|
||||
* 作为模态框容器的元素具有 `dialog` 的角色。
|
||||
* 所有操作模态框所需的元素都是具有 `dialog` 角色的元素的后代。
|
||||
* 模态框容器元素将 `aria-modal` 设置为 `true`。
|
||||
* 模态框具有以下任一条件:
|
||||
* 为 `aria-labelledby` 属性设置一个值,该值引用可见的模态框标题。
|
||||
* 由 `aria-label` 指定的标签。
|
||||
* 可选的 `aria-describedby` 属性设置在具有 `dialog` 角色的元素上,以指示对话框中哪些元素包含描述对话框主要目的或消息的内容。在 [对话框(模态框)模式](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) 中阅读完整的指南。
|
||||
|
||||
#### `<dialog>` 元素
|
||||
|
||||
HTML 现在有一个原生的 `<dialog>` 元素,可以在创建模态对话框时使用,因为它提供了可用性和可访问性功能,如果使用其他元素实现类似目的,则必须复制这些功能。
|
||||
|
||||
但是,它仍然相对较新,并且浏览器兼容性不是很好。 此外,诸如焦点捕获之类的行为仍然必须手动实现,这使得使用原生的 `<dialog>` 元素不太有吸引力。
|
||||
|
||||
### 动画和过渡
|
||||
|
||||
如果需要对模态框元素进行动画处理,并且需要独立地进行覆盖和内容的过渡(例如,覆盖淡入,而内容向上垂直移动),则必须更改 DOM 结构,并且内容无法在覆盖的 DOM 中呈现。 类似于这样的结构是必需的:
|
||||
|
||||
```html
|
||||
<div>
|
||||
<!-- 覆盖层,呈现为内容的固定同级。 -->
|
||||
<div class="modal-overlay" aria-hidden="true"></div>
|
||||
<!-- 全屏容器以居中面板。 -->
|
||||
<div class="modal-contents-container">
|
||||
<div class="modal-contents">...</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
这是一个 React 中进入动画的示例:
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/imlco8?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Modal Animations"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
退出过渡在 React 中实现起来并非易事,因为条件渲染会导致 DOM 元素在页面上不再需要时从文档中删除。 这是一个模态框的示例,其中进入和退出都已动画化。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/t3wwxr?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Modal Animations"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
由于所有面向用户的字符串都由用户提供,因此可以按原样显示字符串。 但是,请注意,某些语言的某些字符串可能很长,因此应截断或换行溢出文本。 文本不应溢出模态框元素。 您通常不希望模态框标题/页脚超过一行,因此建议在此处进行截断。
|
||||
|
||||
对于 RTL 语言,必须水平翻转模态框元素。 为了实现这一点,根模态框组件可以接受一个 `direction` 配置选项/属性,并根据该值呈现内容。
|
||||
|
||||

|
||||
|
||||
### 堆叠模态框
|
||||
|
||||
模态框内容可以包含呈现更多模态框的触发器,因此需要考虑以下几点:
|
||||
|
||||
* 决定是否应该为每个模态框级别提供一个覆盖层,这将使背景在堆叠级别越高时在视觉上变得更暗。
|
||||
* 通过 <kbd>Esc</kbd> 键或单击最顶层模态框内容之外来关闭模态框应该只关闭最顶层的模态框,而不是所有模态框。
|
||||
* 关闭较低层的模态框也应该关闭其上的所有模态框(或使此行为可自定义)。
|
||||
|
||||
### 高级主题
|
||||
|
||||
{/* TODO */}
|
||||
|
||||
* 模态框中的工具提示和下拉菜单。
|
||||
* 警报对话框角色和 [ARIA 模式](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/)。
|
||||
|
||||
***
|
||||
|
||||
## 参考资料
|
||||
|
||||
* 主题示例
|
||||
* [模态框 · Bootstrap v5.3](https://getbootstrap.com/docs/5.3/components/modal)
|
||||
* [React Modal 组件 - Material UI](https://mui.com/material-ui/react-modal/)
|
||||
* 无头示例
|
||||
* [对话框 — Radix UI](https://www.radix-ui.com/docs/primitives/components/dialog)
|
||||
* [对话框 (模态框) — Reach UI](https://reach.tech/dialog)
|
||||
* [对话框 (模态框) - Headless UI](https://headlessui.com/react/dialog)
|
||||
* Aria 创作实践指南 (APG)
|
||||
* [对话框 (模态框) 模式](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
|
||||
* [警报和消息对话框模式](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "ac1337b1",
|
||||
"excerpt": "2ce630fe"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: 音乐流媒体(例如 Spotify)
|
||||
excerpt: 设计一个像 Spotify 和 Pandora 这样的音乐流媒体网站
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
待办事项
|
||||
|
||||
### 真实案例
|
||||
|
||||
* 待办事项
|
||||
* 待办事项
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"6188f3b0"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"6188f3b0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
## 需求探索
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 架构/高层设计
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
TODO
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
TODO
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "c1295c91",
|
||||
"excerpt": "e07b745b"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"36a6675a",
|
||||
"6f97f7ff",
|
||||
"afc3b043",
|
||||
"cdbcdb65",
|
||||
"9ec6d64c",
|
||||
"87134b2b"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"36a6675a",
|
||||
"6f97f7ff",
|
||||
"afc3b043",
|
||||
"cdbcdb65",
|
||||
"9ec6d64c",
|
||||
"87134b2b"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
title: 动态消息(例如 Facebook)
|
||||
excerpt: 设计一个类似于 Facebook 和 Twitter 的动态消息用户界面
|
||||
---
|
||||
|
||||
设计动态消息应用程序是一个经典的系统设计问题,但几乎没有现有资源详细讨论如何设计动态消息的前端。
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个动态消息应用程序,其中包含用户可以与之交互的 feed 帖子列表。
|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* https://www.facebook.com
|
||||
* https://www.twitter.com
|
||||
* https://www.quora.com
|
||||
* https://www.reddit.com
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"fbead67f",
|
||||
"a60bca67",
|
||||
"a6b3c7e6",
|
||||
"c9987981",
|
||||
"2f6c4f8d",
|
||||
"5ed20915",
|
||||
"7ad9a467",
|
||||
"ff29f280",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"46503144",
|
||||
"91d66590",
|
||||
"a13d4ff6",
|
||||
"bc3ae319",
|
||||
"ff39f736",
|
||||
"e1f0e2a6",
|
||||
"fef7c3ac",
|
||||
"6ce73918",
|
||||
"56cf8ea9",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"a4c28f3e",
|
||||
"18ac342b",
|
||||
"45863a7d",
|
||||
"5dd29eef",
|
||||
"9b4fc3c1",
|
||||
"72a9f4a",
|
||||
"88f8eb07",
|
||||
"9f73470d",
|
||||
"b7c7125b",
|
||||
"ea7d3bc8",
|
||||
"bf4da10d",
|
||||
"bd863da9",
|
||||
"8e12b1ae",
|
||||
"aeb98636",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"d426054c",
|
||||
"4fde9cab",
|
||||
"775df49",
|
||||
"17e57acb",
|
||||
"ef569162",
|
||||
"d8a57302",
|
||||
"21708c7d",
|
||||
"a7fb9a87",
|
||||
"9d74abe8",
|
||||
"f3677e0b",
|
||||
"45f878f5",
|
||||
"7c400541",
|
||||
"e0bc49e1",
|
||||
"80e4c4e7",
|
||||
"1042a973",
|
||||
"14332718",
|
||||
"584590e5",
|
||||
"56e5438d",
|
||||
"3fbb0669",
|
||||
"37020cb9",
|
||||
"ed0eff70",
|
||||
"a780fc6f",
|
||||
"e9613b28",
|
||||
"4021c893",
|
||||
"477e4d85",
|
||||
"7d588813",
|
||||
"2ba80a31",
|
||||
"5ee90637",
|
||||
"6e681f6f",
|
||||
"3e7ae801",
|
||||
"861a9c10",
|
||||
"f5395f42",
|
||||
"6fb92bbc",
|
||||
"85d7f53f",
|
||||
"a6ea7626",
|
||||
"1638e0c1",
|
||||
"ef3a514e",
|
||||
"8ef3d21b",
|
||||
"5f9db16c",
|
||||
"71aac11f",
|
||||
"c45a7602",
|
||||
"62346ab4",
|
||||
"54599e7",
|
||||
"7ef757d",
|
||||
"3965dd33",
|
||||
"ff29866c",
|
||||
"8ee0531b",
|
||||
"b55c4a",
|
||||
"4e815d68",
|
||||
"7491146",
|
||||
"934e7ea4",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"2b7008",
|
||||
"394d8c1c",
|
||||
"2091586a",
|
||||
"28de5d7",
|
||||
"929cbe29",
|
||||
"c1dfc31d",
|
||||
"1d8fbed9",
|
||||
"2ab899db",
|
||||
"fa3ee2b3",
|
||||
"c16195b0",
|
||||
"c55ef4e0",
|
||||
"d94f208d",
|
||||
"1616a83f",
|
||||
"538fd5f9",
|
||||
"5c6fc297",
|
||||
"f1d7453c",
|
||||
"c4ef50a6",
|
||||
"f93a01aa",
|
||||
"84492d1b",
|
||||
"97598326",
|
||||
"72da2670",
|
||||
"c54e8e23",
|
||||
"f4abc452",
|
||||
"6530d978",
|
||||
"55a6070f",
|
||||
"2bfac09",
|
||||
"2169c502",
|
||||
"9295b99a",
|
||||
"2901ac65",
|
||||
"55e899c4",
|
||||
"c90ee875",
|
||||
"6d07e30",
|
||||
"1ab01c9b",
|
||||
"9d84ba6b",
|
||||
"c58ad055",
|
||||
"915cf9ea",
|
||||
"519cf25e",
|
||||
"771f6d0d",
|
||||
"bc52c551",
|
||||
"4cb02653",
|
||||
"88f29ed0",
|
||||
"3e9f2a01",
|
||||
"c15841d5",
|
||||
"36c64b77",
|
||||
"56cf73ce",
|
||||
"99405ffd",
|
||||
"1527df27",
|
||||
"34eb1826",
|
||||
"c8cea1cb",
|
||||
"1d2994ab",
|
||||
"42f14676",
|
||||
"26d38ef6",
|
||||
"dc98f4ec",
|
||||
"5877b432",
|
||||
"ad06fbe2",
|
||||
"83e5fcc1",
|
||||
"190374eb",
|
||||
"ce9ab14b",
|
||||
"e5b89797",
|
||||
"2f247d3",
|
||||
"81a6ed51",
|
||||
"4aab58ee",
|
||||
"13507afc",
|
||||
"d5f5659",
|
||||
"291f3e4a",
|
||||
"ad160391",
|
||||
"90e223f1",
|
||||
"8b96e62",
|
||||
"9eb85486",
|
||||
"de0a4ef1",
|
||||
"191f3d0b",
|
||||
"27023b72",
|
||||
"ce8e92b7",
|
||||
"7865087",
|
||||
"e5a4bb17",
|
||||
"bfd1b5e0",
|
||||
"3ab9330c",
|
||||
"7c70a75e",
|
||||
"b2338ff8",
|
||||
"2e6077bb",
|
||||
"3671e6ed",
|
||||
"5a15f34d",
|
||||
"ee0a0dd9",
|
||||
"ae49ff64",
|
||||
"17505c55",
|
||||
"5b993f1d",
|
||||
"5ad78524",
|
||||
"8718d713",
|
||||
"422181de",
|
||||
"5ae4976c",
|
||||
"a55185c8",
|
||||
"1bcf7fff",
|
||||
"785b66f1",
|
||||
"86801db3",
|
||||
"f3ace83",
|
||||
"605580ff",
|
||||
"d94f208d",
|
||||
"87e433eb",
|
||||
"67a7a63d",
|
||||
"7df8ee29",
|
||||
"99aa149c",
|
||||
"6ef28eee",
|
||||
"515e8126",
|
||||
"89413eea",
|
||||
"8b7852ee",
|
||||
"ce37847c",
|
||||
"2f220fd2",
|
||||
"c54b625a",
|
||||
"ae1bc8b6",
|
||||
"fbdadd50",
|
||||
"506da37e",
|
||||
"b2876172",
|
||||
"11e16ea4",
|
||||
"632fe6b6",
|
||||
"a0d604c",
|
||||
"1fbfad23",
|
||||
"d0ccdc6f",
|
||||
"7cfddc64",
|
||||
"1cbac270",
|
||||
"b68ce062",
|
||||
"67148010",
|
||||
"6b5fa1d6",
|
||||
"3c79164c",
|
||||
"266c8d99",
|
||||
"66605fca",
|
||||
"b7fe06bc",
|
||||
"e575750b",
|
||||
"806f66fd",
|
||||
"4e509cc4",
|
||||
"f378cf54",
|
||||
"aeae0942",
|
||||
"44ff4447",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"8e7f7d6f",
|
||||
"97ed4a3a",
|
||||
"316b655d",
|
||||
"9f15dc75"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"fbead67f",
|
||||
"a60bca67",
|
||||
"a6b3c7e6",
|
||||
"c9987981",
|
||||
"2f6c4f8d",
|
||||
"5ed20915",
|
||||
"7ad9a467",
|
||||
"ff29f280",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"46503144",
|
||||
"91d66590",
|
||||
"a13d4ff6",
|
||||
"bc3ae319",
|
||||
"ff39f736",
|
||||
"e1f0e2a6",
|
||||
"fef7c3ac",
|
||||
"6ce73918",
|
||||
"56cf8ea9",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"a4c28f3e",
|
||||
"18ac342b",
|
||||
"45863a7d",
|
||||
"5dd29eef",
|
||||
"9b4fc3c1",
|
||||
"72a9f4a",
|
||||
"88f8eb07",
|
||||
"9f73470d",
|
||||
"b7c7125b",
|
||||
"ea7d3bc8",
|
||||
"bf4da10d",
|
||||
"bd863da9",
|
||||
"8e12b1ae",
|
||||
"aeb98636",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"d426054c",
|
||||
"4fde9cab",
|
||||
"775df49",
|
||||
"17e57acb",
|
||||
"ef569162",
|
||||
"d8a57302",
|
||||
"21708c7d",
|
||||
"a7fb9a87",
|
||||
"9d74abe8",
|
||||
"f3677e0b",
|
||||
"45f878f5",
|
||||
"7c400541",
|
||||
"e0bc49e1",
|
||||
"80e4c4e7",
|
||||
"1042a973",
|
||||
"14332718",
|
||||
"584590e5",
|
||||
"56e5438d",
|
||||
"3fbb0669",
|
||||
"37020cb9",
|
||||
"ed0eff70",
|
||||
"a780fc6f",
|
||||
"e9613b28",
|
||||
"4021c893",
|
||||
"477e4d85",
|
||||
"7d588813",
|
||||
"2ba80a31",
|
||||
"5ee90637",
|
||||
"6e681f6f",
|
||||
"3e7ae801",
|
||||
"861a9c10",
|
||||
"f5395f42",
|
||||
"6fb92bbc",
|
||||
"85d7f53f",
|
||||
"a6ea7626",
|
||||
"1638e0c1",
|
||||
"ef3a514e",
|
||||
"8ef3d21b",
|
||||
"5f9db16c",
|
||||
"71aac11f",
|
||||
"c45a7602",
|
||||
"62346ab4",
|
||||
"54599e7",
|
||||
"7ef757d",
|
||||
"3965dd33",
|
||||
"ff29866c",
|
||||
"8ee0531b",
|
||||
"b55c4a",
|
||||
"4e815d68",
|
||||
"7491146",
|
||||
"934e7ea4",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"2b7008",
|
||||
"394d8c1c",
|
||||
"2091586a",
|
||||
"28de5d7",
|
||||
"929cbe29",
|
||||
"c1dfc31d",
|
||||
"1d8fbed9",
|
||||
"2ab899db",
|
||||
"fa3ee2b3",
|
||||
"c16195b0",
|
||||
"c55ef4e0",
|
||||
"d94f208d",
|
||||
"1616a83f",
|
||||
"538fd5f9",
|
||||
"5c6fc297",
|
||||
"f1d7453c",
|
||||
"c4ef50a6",
|
||||
"f93a01aa",
|
||||
"84492d1b",
|
||||
"97598326",
|
||||
"72da2670",
|
||||
"c54e8e23",
|
||||
"f4abc452",
|
||||
"6530d978",
|
||||
"55a6070f",
|
||||
"2bfac09",
|
||||
"2169c502",
|
||||
"9295b99a",
|
||||
"2901ac65",
|
||||
"55e899c4",
|
||||
"c90ee875",
|
||||
"6d07e30",
|
||||
"1ab01c9b",
|
||||
"9d84ba6b",
|
||||
"c58ad055",
|
||||
"915cf9ea",
|
||||
"519cf25e",
|
||||
"771f6d0d",
|
||||
"bc52c551",
|
||||
"4cb02653",
|
||||
"88f29ed0",
|
||||
"3e9f2a01",
|
||||
"c15841d5",
|
||||
"36c64b77",
|
||||
"56cf73ce",
|
||||
"99405ffd",
|
||||
"1527df27",
|
||||
"34eb1826",
|
||||
"c8cea1cb",
|
||||
"1d2994ab",
|
||||
"42f14676",
|
||||
"26d38ef6",
|
||||
"dc98f4ec",
|
||||
"5877b432",
|
||||
"ad06fbe2",
|
||||
"83e5fcc1",
|
||||
"190374eb",
|
||||
"ce9ab14b",
|
||||
"e5b89797",
|
||||
"2f247d3",
|
||||
"81a6ed51",
|
||||
"4aab58ee",
|
||||
"13507afc",
|
||||
"d5f5659",
|
||||
"291f3e4a",
|
||||
"ad160391",
|
||||
"90e223f1",
|
||||
"8b96e62",
|
||||
"9eb85486",
|
||||
"de0a4ef1",
|
||||
"191f3d0b",
|
||||
"27023b72",
|
||||
"ce8e92b7",
|
||||
"7865087",
|
||||
"e5a4bb17",
|
||||
"bfd1b5e0",
|
||||
"3ab9330c",
|
||||
"7c70a75e",
|
||||
"b2338ff8",
|
||||
"2e6077bb",
|
||||
"3671e6ed",
|
||||
"5a15f34d",
|
||||
"ee0a0dd9",
|
||||
"ae49ff64",
|
||||
"17505c55",
|
||||
"5b993f1d",
|
||||
"5ad78524",
|
||||
"8718d713",
|
||||
"422181de",
|
||||
"5ae4976c",
|
||||
"a55185c8",
|
||||
"1bcf7fff",
|
||||
"785b66f1",
|
||||
"86801db3",
|
||||
"f3ace83",
|
||||
"605580ff",
|
||||
"d94f208d",
|
||||
"87e433eb",
|
||||
"67a7a63d",
|
||||
"7df8ee29",
|
||||
"99aa149c",
|
||||
"6ef28eee",
|
||||
"515e8126",
|
||||
"89413eea",
|
||||
"8b7852ee",
|
||||
"ce37847c",
|
||||
"2f220fd2",
|
||||
"c54b625a",
|
||||
"ae1bc8b6",
|
||||
"fbdadd50",
|
||||
"506da37e",
|
||||
"b2876172",
|
||||
"11e16ea4",
|
||||
"632fe6b6",
|
||||
"a0d604c",
|
||||
"1fbfad23",
|
||||
"d0ccdc6f",
|
||||
"7cfddc64",
|
||||
"1cbac270",
|
||||
"b68ce062",
|
||||
"67148010",
|
||||
"6b5fa1d6",
|
||||
"3c79164c",
|
||||
"266c8d99",
|
||||
"66605fca",
|
||||
"b7fe06bc",
|
||||
"e575750b",
|
||||
"806f66fd",
|
||||
"4e509cc4",
|
||||
"f378cf54",
|
||||
"aeae0942",
|
||||
"44ff4447",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"8e7f7d6f",
|
||||
"97ed4a3a",
|
||||
"316b655d",
|
||||
"9f15dc75"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,707 @@
|
|||
## 需求探索
|
||||
|
||||
### 要支持哪些核心功能?
|
||||
|
||||
* 浏览包含用户及其朋友帖子的新闻 Feed。
|
||||
* 喜欢和回应 Feed 帖子。
|
||||
* 创建和发布新帖子。
|
||||
|
||||
评论和分享将在下面进一步讨论,但未包含在核心范围内。
|
||||
|
||||
### 支持哪些类型的帖子?
|
||||
|
||||
主要是基于文本和图像的帖子。 如果时间允许,我们可以讨论更多类型的帖子。
|
||||
|
||||
### Feed 应该使用什么分页 UX?
|
||||
|
||||
无限滚动,这意味着当用户到达 Feed 末尾时,将添加更多帖子。
|
||||
|
||||
### 该应用程序是否会在移动设备上使用?
|
||||
|
||||
不是优先事项,但良好的移动体验会很好。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||

|
||||
|
||||
### 组件职责
|
||||
|
||||
* **服务器**:提供 HTTP API 以获取 Feed 帖子和创建新的 Feed 帖子。
|
||||
* **控制器**:控制应用程序内的数据流并向服务器发出网络请求。
|
||||
* **客户端存储**:存储整个应用程序所需的数据。 在新闻 Feed 的上下文中,存储中的大多数数据将是 Feed UI 所需的服务器生成的数据。
|
||||
* **Feed UI**:包含 Feed 帖子列表和用于撰写新帖子的 UI。
|
||||
* **Feed 帖子**:呈现 Feed 帖子的数据,并包含用于与帖子交互的按钮(喜欢/回应/分享)。
|
||||
* **帖子撰写器**:WYSIWYG(所见即所得)编辑器,供用户创建新的 Feed 帖子。
|
||||
|
||||
### 渲染方法
|
||||
|
||||
传统的 Web 应用程序在何处呈现内容方面有多种选择,无论是在服务器端还是客户端呈现。
|
||||
|
||||
* **服务器端渲染 (SSR)**:在服务器端渲染 HTML,这是最传统的方式。最适合需要 SEO 且不需要大量用户交互的静态内容。 博客、文档站点、电子商务网站等网站都是使用 SSR 构建的。
|
||||
* **客户端渲染 (CSR)**:在浏览器中呈现,通过使用 JavaScript 将 DOM 元素动态添加到页面中。最适合交互式内容。 仪表板、聊天应用程序等应用程序是使用 CSR 构建的。
|
||||
|
||||
有趣的是,新闻 feed 应用程序介于两者之间,既有大量静态内容,又需要交互。 实际上,Facebook 使用了一种混合方法,它提供了两全其美的效果:使用 SSR 快速初始加载,然后对页面进行水合以附加用于用户交互的事件侦听器。 后续内容(例如,当用户到达 feed 末尾时添加的更多帖子)和页面导航将使用 CSR。
|
||||
|
||||
现代 UI JavaScript 框架(如 React 和 Vue)以及元框架(如 [Next.js](https://nextjs.org/) 和 [Nuxt](https://nuxtjs.org/))支持此渲染策略。
|
||||
|
||||
阅读有关 [在 Web 上渲染](https://web.dev/rendering-on-the-web/) 和 ["为新的 Facebook.com 重建我们的技术堆栈" 博客文章](https://engineering.fb.com/2020/05/08/web/facebook-redesign/) 的更多信息。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
新闻 feed 显示从服务器获取的帖子列表,因此此应用程序中涉及的大部分数据将是服务器生成的数据。 唯一需要的客户端数据是帖子撰写器中输入字段的表单状态。
|
||||
|
||||
| 实体 | 来源 | 属于 | 字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Feed` | 服务器 | Feed UI | `posts` ( `Post` 列表), `pagination` (分页元数据) |
|
||||
| `Post` | 服务器 | Feed 帖子 | `id`, `created_time`, `content`, `author` (一个 `User`), `reactions`, `image_url` (对于包含图像的帖子) |
|
||||
| `User` | 服务器 | 客户端存储 | `id`, `name`, `profile_photo_url` |
|
||||
| `NewPost` | 用户输入 (客户端) | 帖子撰写器 UI | `message`, `image` |
|
||||
|
||||
尽管 `Post` 和 `Feed` 实体分别属于 feed 帖子和 feed UI,但所有服务器生成的数据都可以存储在客户端存储中,并由需要它们的组件查询。
|
||||
|
||||
客户端存储的形状在这里并不特别重要,只要它采用可以从组件轻松检索的格式即可。 从第二页获取的新帖子应与之前的帖子合并到一个列表中,并更新分页参数 (`cursor`)。
|
||||
|
||||
### 高级:规范化存储
|
||||
|
||||
Facebook 和 Twitter 都使用规范化的客户端存储。 如果术语“规范化”对您来说是新的,请阅读 [Redux 关于规范化状态形状的文档](https://redux.js.org/usage/structuring-reducers/normalizing-state-shape)。 简而言之,规范化数据存储:
|
||||
|
||||
* 类似于数据库,其中每种类型的数据都存储在自己的表中。
|
||||
* 每个项目都有一个唯一的 ID。
|
||||
* 跨数据类型的引用使用 ID(如外键),而不是嵌套对象。
|
||||
|
||||
Facebook 使用 [Relay](https://relay.dev)(由于了解 GraphQL 模式,可以规范化数据),而 Twitter 使用 [Redux](https://redux.js.org/),如 ["剖析 Twitter 的 Redux 存储" 博客文章](https://medium.com/statuscode/dissecting-twitters-redux-store-d7280b62c6b1) 所示。
|
||||
|
||||
拥有规范化存储的好处是:
|
||||
|
||||
* **减少重复数据**:同一数据片段的单一事实来源,可以在 UI 上的多个实例中呈现。 例如,如果许多帖子是由同一作者撰写的,我们将在客户端存储中存储 `author` 字段的重复数据。
|
||||
* **轻松更新同一实体的所有数据**:在 feed 帖子包含用户撰写的许多帖子并且该用户更改其姓名的情况下,能够立即在 UI 中反映更新的作者姓名会很好。 使用规范化存储比仅存储服务器响应原文的存储更容易做到这一点。
|
||||
|
||||
在面试的背景下,我们实际上不需要为新闻 feed 使用规范化存储,因为:
|
||||
|
||||
* 除了用户/作者字段外,没有太多重复数据。
|
||||
* 新闻 feed 主要用于消费信息,没有太多更新数据的用例。 Feed 用户交互(例如点赞)仅影响 feed 帖子中的数据。
|
||||
|
||||
因此,使用规范化存储的优势是有限的。 实际上,Facebook 和 Twitter 网站包含许多其他功能,这些功能将受益于规范化存储提供的功能。
|
||||
|
||||
*延伸阅读:[让 Instagram.com 速度更快:第 3 部分 — 缓存优先](https://instagram-engineering.com/making-instagram-com-faster-part-3-cache-first-6f3f130b9669)*
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
| 来源 | 目的地 | API 类型 | 功能 |
|
||||
| ------------- | ----------- | ---------- | ----------------------- |
|
||||
| 服务器 | 控制器 | HTTP | 获取 feed 帖子 |
|
||||
| 控制器 | 服务器 | HTTP | 创建新帖子 |
|
||||
| 控制器 | Feed UI | JavaScript | 传递 feed 帖子数据,反应 |
|
||||
| 帖子撰写器 | 控制器 | JavaScript | 传递新帖子数据 |
|
||||
|
||||
最有趣的 API 莫过于讨论获取 feed 帖子列表的 HTTP API,因为分页方法值得讨论。从服务器获取 feed 帖子的 HTTP API 具有基本细节:
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ------------------------------------ |
|
||||
| HTTP 方法 | `GET` |
|
||||
| 路径 | `/feed` |
|
||||
| 描述 | 获取用户的 feed 结果。 |
|
||||
|
||||
有两种常见的方式来返回分页内容,每种方式都有其自身的优缺点。
|
||||
|
||||
* 基于偏移的分页
|
||||
* 基于游标的分页
|
||||
|
||||
{/* TODO: 分页应该在设备之间工作,服务器不应该保存最后发送帖子的副本。 */}
|
||||
|
||||
### 基于偏移的分页
|
||||
|
||||
基于偏移的分页涉及使用偏移量来指定从哪里开始检索数据,并使用限制来指定要检索的项目的数量。这就像说,“从第 10 条记录开始,给我接下来的 5 个项目”。偏移量可以是一个显式数字,也可以从请求的页码转换而来。请求第 3 页,页面大小为 5,将转换为偏移量 10,因为在第 3 页之前有 2 页,2 x 5 = 10。对于基于偏移的分页 API 来说,更常见的是接受 `page` 参数,并且服务器将在查询数据库时将其转换为 `offset` 值。
|
||||
|
||||
基于偏移的分页 API 接受以下参数:
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ------ | ------------------------ |
|
||||
| `size` | number | 每页的项目数 |
|
||||
| `page` | number | 要获取的页码 |
|
||||
|
||||
给定 feed 中有 20 个项目,参数 `{size: 5, page: 2}` 将返回项目 6 - 10 以及分页元数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"size": 5,
|
||||
"page": 2,
|
||||
"total_pages": 4,
|
||||
"total": 20
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "123",
|
||||
"author": {
|
||||
"id": "456",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"content": "Hello world",
|
||||
"image": "https://www.example.com/feed-images.jpg",
|
||||
"reactions": {
|
||||
"likes": 20,
|
||||
"haha": 15
|
||||
},
|
||||
"created_time": 1620639583
|
||||
}
|
||||
// ... More posts.
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
并且底层的 SQL 查询类似于:
|
||||
|
||||
```sql
|
||||
SELECT * FROM posts LIMIT 5 OFFSET 0; -- 第一页
|
||||
SELECT * FROM posts LIMIT 5 OFFSET 5; -- 第二页
|
||||
```
|
||||
|
||||
基于偏移的分页具有以下优点:
|
||||
|
||||
* 用户可以直接跳转到特定页面。
|
||||
* 易于查看总页数。
|
||||
* 易于在后端实现。 SQL 查询的 `OFFSET` 值使用 `(page - 1) * size` 计算。
|
||||
* 易于与各种数据库系统一起使用,并且不依赖于特定的数据存储机制
|
||||
|
||||
但是,基于偏移的分页存在一些问题:
|
||||
|
||||
**不准确的页面结果**:对于经常更新的数据,当前页面窗口在一段时间后可能不准确。想象一下,用户已经获取了 feed 中的前 5 个帖子。一段时间后,又添加了 5 个帖子。如果用户滚动到 feed 的底部并获取第 2 页,将获取原始第 1 页中的相同帖子,并且用户将看到重复的帖子。
|
||||
|
||||
```txt
|
||||
// 初始帖子(最新的在左边,最旧的在右边)
|
||||
帖子:A、B、C、D、E、F、G、H、I、J
|
||||
^^^^^^^^^^^^^ 第 1 页包含 A - E
|
||||
|
||||
// 随着时间的推移添加的新帖子
|
||||
帖子:K、L、M、N、O、A、B、C、D、E、F、G、H、I、J
|
||||
^^^^^^^^^^^^^ 第 2 页也包含 A - E
|
||||
```
|
||||
|
||||
客户端可以尝试变得智能,通过不显示已经可见的帖子来去重。然而,这需要自定义逻辑,并且客户端将不得不发出新的请求以弥补缺少的新帖子,这会产生额外的网络往返。对于项目数量会随着时间减少的用例,页面最终可能会丢失一些项目。
|
||||
|
||||
**页面大小无法轻易更改**:基于偏移量的分页的另一个缺点是,客户端无法更改后续查询的页面大小,因为偏移量是页面大小和所请求页面的乘积。
|
||||
|
||||
| 页面 | 页面大小 | 结果 |
|
||||
| ---- | --------- | ------------ |
|
||||
| 1 | 5 | 项目 1 - 5 |
|
||||
| 2 | 5 | 项目 6 - 10 |
|
||||
| 2 | 7 | 项目 8 - 14 |
|
||||
|
||||
在上面的例子中,如果客户端从`{page: 1, size: 5}`变为`{page: 2, size: 7}`,它将错过项目 6 和 7。
|
||||
|
||||
**查询性能随时间推移而下降**:最后,查询性能会随着表格变大而下降。对于巨大的偏移量(例如`OFFSET 1000000`),数据库仍然必须读取多达`count` + `offset`行,丢弃`offset`行,并且仅返回`count`行,这会导致大型偏移量的查询性能非常差。这被认为是后端知识,但了解它很有用,并且您可能会因为提及它而获得奖励。
|
||||
|
||||
基于偏移量的分页在 Web 应用程序中很常见,用于显示搜索结果等列表,其中需要跳转到特定页面,并且结果不会更新得太快。因此,博客、旅游预订网站、电子商务网站将受益于使用基于偏移量的分页来获取其搜索结果。
|
||||
|
||||
### 基于游标的分页
|
||||
|
||||
基于游标的分页使用指针(游标)指向数据集中的特定记录。它不是说“给我项目 11 到 15”,而是说“给我从 \[特定项目] 开始的 5 个项目”。
|
||||
|
||||
游标通常是唯一标识符,可以是项目 ID、时间戳或其他内容。后续请求使用最后一个项目的标识符作为游标来获取下一组项目。在 SQL 中,一个例子是:
|
||||
|
||||
```sql
|
||||
SELECT * FROM table WHERE id > cursor LIMIT 5.
|
||||
```
|
||||
|
||||
基于游标的分页 API 接受以下参数:
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `size` | number | 每页的结果数 |
|
||||
| `cursor` | string | 最后一个获取项目的标识符。数据库查询将使用此标识符。 |
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"size": 10,
|
||||
"next_cursor": "=dXNlcjpVMEc5V0ZYTlo"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "123",
|
||||
"author": {
|
||||
"id": "456",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"content": "Hello world",
|
||||
"image": "https://www.example.com/feed-images.jpg",
|
||||
"reactions": {
|
||||
"likes": 20,
|
||||
"haha": 15
|
||||
},
|
||||
"created_time": 1620639583
|
||||
}
|
||||
// ... More posts.
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
基于游标的分页的优点:
|
||||
|
||||
* 在大型数据集上更高效、更快。
|
||||
* 避免了不准确的页面窗口问题,因为随着时间的推移添加的新帖子不会影响偏移量,偏移量由固定的游标确定。非常适合实时数据。
|
||||
|
||||
[Facebook](https://developers.facebook.com/docs/graph-api/reference/page/feed/)、[Slack](https://api.slack.com/docs/pagination#cursors)、[Zendesk](https://developer.zendesk.com/documentation/developer-tools/pagination/paginating-through-lists-using-cursor-pagination/) 使用基于游标的分页来获取其开发者 API。
|
||||
|
||||
基于游标的分页的缺点:
|
||||
|
||||
* 由于客户端不知道游标,因此无法在不浏览前几页的情况下跳转到特定页面。
|
||||
* 与基于偏移量的分页相比,实现起来稍微复杂一些。
|
||||
|
||||
为了使后端实现基于游标的分页,数据库需要将游标唯一地映射到一行,这可以使用数据库表的主键或在某些情况下使用时间戳来完成。
|
||||
|
||||
### 为新闻提要使用哪种分页?
|
||||
|
||||
简而言之,基于偏移量的分页和基于游标的分页之间的选择很大程度上取决于数据和需求。基于偏移量的方式更简单,更适合静态或小型数据集,其中直接访问页面很重要。基于游标的方式对于大型、动态数据集更有效、更可靠,其中数据序列很重要并且经常变化。
|
||||
|
||||
对于无限滚动的动态消息流,其中:
|
||||
|
||||
* 新帖子可以经常添加到 feed 的顶部。
|
||||
* 新获取的帖子会附加到 feed 的末尾。
|
||||
* 表格大小增长很快。
|
||||
|
||||
基于游标的分页显然更胜一筹,应该用于新闻 feed。
|
||||
|
||||
*参考:[在 Slack 上演进的 API 分页](https://slack.engineering/evolving-api-pagination-at-slack)*
|
||||
|
||||
### 创建帖子
|
||||
|
||||
此 HTTP 方法供用户创建新帖子,该帖子将显示在他们自己的 feed 以及他们是朋友的其他人的 feed 中。
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ------------------------------- |
|
||||
| HTTP 方法 | `POST` |
|
||||
| 路径 | `/posts` |
|
||||
| 描述 | 创建一个新帖子。 |
|
||||
| 参数 | `{ body: '...', media: '...' }` |
|
||||
|
||||
HTTP API 的参数将取决于所制作的帖子类型。在大多数情况下,这在面试中不是一个关键的讨论点。
|
||||
|
||||
响应格式可以是单个帖子,也可以是 feed 中最新帖子的列表。如果返回单个帖子,则 API 响应将类似于 feed API 中的 feed 帖子项:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "124",
|
||||
"author": {
|
||||
"id": "456",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"content": "Hello world",
|
||||
"image": {
|
||||
"src": "https://www.example.com/feed-images.jpg",
|
||||
"alt": "An image alt" // Either user-provided, or generated on the server.
|
||||
// Other useful properties can be included too, such as dimensions.
|
||||
},
|
||||
"reactions": {
|
||||
"likes": 20,
|
||||
"haha": 15
|
||||
},
|
||||
"created_time": 1620639583
|
||||
}
|
||||
```
|
||||
|
||||
给定此新帖子数据,客户端存储将需要将其添加到 feed 列表的开头。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
由于新闻 feed 应用程序中有几个部分,因此一次专注于一个部分并查看可以对特定部分进行的优化会更有条理:
|
||||
|
||||
* [一般优化](#general-optimizations)
|
||||
* [Feed 列表优化](#feed-list-optimizations)
|
||||
* [Feed 帖子优化](#feed-post-optimizations)
|
||||
* [Feed composer 优化](#feed-composer-optimizations)
|
||||
|
||||
### 一般优化
|
||||
|
||||
这些优化适用于页面的每个部分。
|
||||
|
||||
#### 对 JavaScript 进行代码拆分以提高性能
|
||||
|
||||
随着应用程序的增长,页面和功能的数量会增加,这将导致需要运行应用程序的 JavaScript 和 CSS 代码更多。代码拆分是一种将页面上所需代码拆分为单独文件的技术,以便可以并行或在需要时加载它们。
|
||||
|
||||
通常,代码拆分可以在两个级别上完成:
|
||||
|
||||
* **在页面级别拆分**:每个页面将仅加载该页面所需的 JavaScript 和 CSS。
|
||||
* **在页面内延迟加载资源**:仅在需要时或在初始渲染后加载非关键资源,例如仅在页面下方需要或仅在交互时使用的代码(例如模态框、对话框)。
|
||||
|
||||
对于新闻 feed 应用程序,只有一个页面,因此页面级别的代码拆分不太相关,但是懒加载对于其他目的仍然非常有用。懒加载在 feed 帖子部分有更详细的讨论,因为它与 feed 帖子 UI 最相关。
|
||||
|
||||
作为参考,Facebook 将其 JavaScript 加载分为 3 个层级:
|
||||
|
||||
* **Tier 1**: 显示高于折叠内容所需的基本布局,包括用于初始加载状态的 UI 骨架。
|
||||
* **Tier 2**: 完全渲染所有高于折叠内容所需的 JavaScript。在 Tier 2 之后,屏幕上不应再有任何内容因代码加载而发生视觉变化。
|
||||
* **Tier 3**: 仅在显示后才需要的资源,这些资源不影响屏幕上的当前像素,包括日志记录代码和用于实时更新数据的订阅。
|
||||
|
||||
*来源:[“为新的 Facebook.com 重建我们的技术堆栈”博客文章](https://engineering.fb.com/2020/05/08/web/facebook-redesign/)*
|
||||
|
||||
#### 键盘快捷键
|
||||
|
||||
Facebook 有许多特定于新闻 feed 的快捷方式,可帮助用户在帖子之间导航并执行常见操作,非常方便!通过在 facebook.com 上点击“<kbd>Shift</kbd> + <kbd>?</kbd>”键来亲自尝试。
|
||||
|
||||

|
||||
|
||||
*来源:[让 Facebook.com 尽可能多的人访问](https://engineering.fb.com/2020/07/30/web/facebook-com-accessibility/)*
|
||||
|
||||
#### 错误状态
|
||||
|
||||
如果任何网络请求失败,或者没有网络连接,请清楚地显示错误状态。
|
||||
|
||||
### Feed 列表优化
|
||||
|
||||
Feed 列表指的是包含 Feed 帖子项目的容器元素。
|
||||
|
||||
#### 无限滚动
|
||||
|
||||
当用户滚动到当前已加载 feed 的末尾时,无限滚动 feed 会通过获取下一组帖子来工作。这会导致用户看到一个加载指示器和一个短暂的延迟,用户必须等待获取和显示新帖子。
|
||||
|
||||
减少或完全消除等待时间的一种方法是在用户到达页面底部之前加载下一组 feed 帖子,这样用户就永远不必看到任何加载指示器。
|
||||
|
||||
对于大多数情况,大约一个视口高度的触发距离就足够了。理想的距离足够短,以避免出现误报和浪费带宽,但也足够宽,可以在用户滚动到页面底部之前加载其余内容。可以根据网络连接速度和用户浏览 feed 的速度来计算动态距离。
|
||||
|
||||
有两种常用的方法来实现无限滚动。两者都涉及在 feed 底部呈现一个标记元素:
|
||||
|
||||
1. **监听 `scroll` 事件**:将 `scroll` 事件侦听器(最好是受限的)添加到页面或计时器(通过 `setInterval`),该计时器检查标记元素的位置是否在距页面底部的某个阈值内。可以使用 [`Element.getBoundingClientRect`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) 获取标记元素的位置。
|
||||
2. **Intersection Observer API**:使用 [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) 来监视标记元素何时进入或退出另一个元素或与另一个元素相交,或相交量发生指定的变化。
|
||||
|
||||
Intersection Observer API 是一个浏览器原生 API,优于 `Element.getBoundingClientRect()`。
|
||||
|
||||
> Intersection Observer API 允许代码注册一个回调函数,只要它们希望监视的元素进入或退出另一个元素(或视口),或者两个元素相交的量发生请求的变化时,就会执行该函数。这样,站点不再需要在主线程上执行任何操作来监视这种元素相交,并且浏览器可以自由地根据需要优化相交的管理。
|
||||
|
||||
*来源:[Intersection Observer API | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)*
|
||||
|
||||
#### 虚拟列表
|
||||
|
||||
使用无限滚动时,所有已加载的 feed 项目都在一个页面上。当用户向下滚动页面时,更多帖子会附加到 DOM 中,并且 feed 帖子具有复杂的 DOM 结构(需要渲染大量细节),DOM 大小会迅速增加。由于社交媒体网站是长期运行的应用程序(特别是如果单页应用程序),并且 feed 项目列表很容易快速增长,因此 feed 项目的数量可能会导致 DOM 大小、渲染和浏览器内存使用方面的性能问题。
|
||||
|
||||
虚拟列表是一种仅渲染视口内帖子的技术。在实践中,Facebook 将屏幕外 feed 帖子的内容替换为空的 `<div>`,添加动态计算的内联样式(例如 `style="height: 300px"`)以设置帖子的高度,从而保留滚动位置,并将 [`hidden` 属性](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/hidden) 添加到它们。这将提高渲染性能,具体表现在:
|
||||
|
||||
* **虚拟 DOM 对账(React 特有)**:由于帖子现在是一个更简单的空版本,React(Facebook 用于渲染 feed 的 UI 库)更容易区分虚拟 DOM 与真实 DOM,以确定必须进行哪些 DOM 更新。
|
||||
|
||||
Facebook 和 Twitter 网站都使用虚拟列表。
|
||||
|
||||
#### 加载指示器
|
||||
|
||||
对于滚动速度非常快的用户,即使浏览器在用户到达页面底部之前就开始请求下一组帖子,该请求可能尚未返回,并且应显示加载指示器以反映请求状态。
|
||||
|
||||
与其显示微调器,不如使用 [shimmer 加载效果](https://docs.flutter.dev/cookbook/effects/shimmer-loading)(类似于帖子的内容)会更好。这看起来更美观,也可以用于减少新帖子加载后的布局抖动。
|
||||
|
||||
Facebook feed 加载 shimmer 的示例:
|
||||
|
||||

|
||||
|
||||
#### 动态加载计数
|
||||
|
||||
如上所述,在“界面”部分中,基于游标的分页更适合新闻 feed。基于游标的分页的一个好处是客户端可以更改在后续调用中要获取的条目数。我们可以通过根据浏览器窗口高度自定义要加载的帖子数来利用这一点。
|
||||
|
||||
对于初始加载,我们不知道窗口高度,因此我们需要保守地过度获取所需的帖子数。但是对于后续获取,我们知道浏览器窗口高度,并且可以根据该高度自定义要获取的帖子数。
|
||||
|
||||
#### 在重新挂载时保留 feed 滚动位置
|
||||
|
||||
如果用户导航到另一个页面并返回到 feed,则应保留 feed 滚动位置。如果 feed 列表数据与滚动位置一起缓存在客户端存储中,则可以在单页应用程序中实现此目的。当用户返回到 feed 页面时,由于数据已在客户端上,因此可以从客户端存储中读取 feed 列表,并立即在屏幕上显示之前的滚动位置;不需要服务器往返。
|
||||
|
||||
#### 过时的 feed
|
||||
|
||||
用户将新闻 feed 应用程序作为浏览器选项卡打开而不刷新的情况并不少见。如果上次获取的时间戳超过几个小时,则最好提示用户刷新或重新获取 feed,因为可能存在新帖子,并且已加载的 feed 被视为过时。当重新获取新 feed 时,可以从内存中完全删除当前 feed 以释放内存空间。
|
||||
|
||||
另一种方法是自动将新的 feed 帖子附加到 feed 的顶部,但这可能不是期望的,并且必须格外小心,以免影响滚动位置。
|
||||
|
||||
截至撰写本文时,如果选项卡已打开一段时间,Facebook 会强制刷新 feed 并滚动到顶部。
|
||||
|
||||
### Feed 帖子优化
|
||||
|
||||
帖子是指包含帖子详细信息的单个帖子元素:作者、时间戳、内容、点赞/评论按钮。
|
||||
|
||||
#### 仅在需要时提供数据驱动的依赖项
|
||||
|
||||
新闻 feed 帖子可以有许多不同的格式(文本、图像、视频、轮询等),并且每个帖子都需要自定义渲染代码。 实际上,Facebook feed 支持 50 多种不同的帖子格式!
|
||||
|
||||
支持客户端上所有帖子格式的一种方法是让客户端预先加载所有可能格式的组件 JavaScript 代码,以便可以渲染任何类型的 feed 帖子格式。 但是,并非所有用户的 feed 都会包含所有帖子格式,并且很可能存在大量未使用的 JavaScript。 考虑到 feed 帖子格式的多样性,预先加载所有格式的 JavaScript 代码肯定会导致性能问题。
|
||||
|
||||
如果我们只能根据接收到的数据延迟加载组件就好了! 这已经成为可能,但需要额外的网络往返来延迟加载组件,在获取数据后,我们知道渲染的帖子类型。
|
||||
|
||||
Facebook 使用基于 JavaScript 的 GraphQL 客户端 [Relay](https://relay.dev) 从服务器获取数据。 Relay 将 React 组件与 GraphQL 结合起来,允许 React 组件准确地声明需要哪些数据字段,Relay 将通过 GraphQL 获取它们,并为组件提供数据。 Relay 有一个名为 [数据驱动依赖项](https://relay.dev/docs/glossary/#match) 的功能,通过 `@match` 和 `@module` GraphQL 指令,它会获取组件代码以及相应的数据类型,从而有效地解决了上述多余组件的问题,而无需额外的网络往返。 您仅在显示帖子的特定格式时加载相关代码。
|
||||
|
||||
```js
|
||||
// 演示数据驱动依赖项的示例 GraphQL 查询。
|
||||
... on Post {
|
||||
... on TextPost {
|
||||
@module('TextComponent.js')
|
||||
contents
|
||||
}
|
||||
... on ImagePost {
|
||||
@module('ImageComponent.js')
|
||||
image_data {
|
||||
alt
|
||||
dimensions
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上面的 GraphQL 查询告诉后端返回 `TextComponent` JavaScript 代码以及文本内容(如果帖子是基于文本的帖子),并返回 `ImageComponent` JavaScript 代码以及图像数据(如果帖子有图像附件)。 客户端无需预先加载所有可能帖子格式的组件 JavaScript 代码,从而减少了页面上所需的初始 JavaScript。
|
||||
|
||||
*来源:[重建我们的技术堆栈以适应新的 Facebook.com](https://engineering.fb.com/2020/05/08/web/facebook-redesign/)*
|
||||
|
||||
#### 渲染提及/主题标签
|
||||
|
||||
您可能已经注意到,feed 帖子中的文本内容不仅仅是纯文本。 对于社交媒体应用程序,通常会看到提及和主题标签。
|
||||
|
||||

|
||||
|
||||
在 Stephen Curry 的上述帖子中,请注意他使用了“#AboutLastNight”主题标签并提到了“HBO Max”Facebook 页面。 他的帖子消息必须以特殊格式存储,以便它包含有关这些标签和提及的元数据。
|
||||
|
||||
消息应该采用什么格式才能存储有关提及/主题标签的数据? 让我们讨论可能的格式及其优缺点。
|
||||
|
||||
**HTML 格式**:最简单的格式是 HTML,您可以按照您希望显示的方式存储消息。
|
||||
|
||||
```md
|
||||
<a href="...">#AboutLastNight</a> is here... and ready to change the meaning of date night...
|
||||
|
||||
Absolute comedy 🤣 Dropping 2/10 on <a href="...">HBO Max</a>!
|
||||
```
|
||||
|
||||
存储为 HTML 通常是不好的,因为它有可能导致跨站点脚本 (XSS) 漏洞。 此外,在大多数情况下,最好将消息的元数据与显示分离,也许将来您想在渲染之前修饰提及/主题标签,并想将类名添加到链接中。 HTML 格式也使得 API 在非 Web 客户端(例如 iOS/Android)上的可重用性降低。
|
||||
|
||||
**自定义语法**:可以使用自定义语法来捕获有关主题标签和提及的元数据。
|
||||
|
||||
* **主题标签**:主题标签实际上不需要特殊的语法,以“#”开头的单词可以被视为主题标签。
|
||||
* **提及**:像 `[[#1234: HBO Max]]` 这样的语法足以捕获实体 ID 和要显示的文本。 仅存储实体 ID 是不够的,因为像 Facebook 这样的网站允许用户自定义提及中的文本。
|
||||
|
||||
在渲染消息之前,可以使用正则表达式解析字符串以获取主题标签和提及,并将其替换为自定义样式的链接。 如果您不希望将来支持新的富文本实体,则自定义语法是一种轻量级的解决方案,它足够强大。
|
||||
|
||||
**富文本编辑器格式**:[Draft.js](https://draftjs.org) 是 Meta 推出的一款流行的富文本编辑器,用于编写富文本。Draft.js 允许用户扩展功能并创建自己的富文本实体,例如主题标签和提及。它定义了一个自定义的 Draft.js 编辑器状态格式,Draft.js 编辑器正在使用该格式。2022 年,Meta 发布了 [Lexical](https://lexical.dev/),它是 Draft.js 的后继产品,并且正在使用 Lexical 在 facebook.com 上进行富文本编辑和显示富文本实体。底层格式是相似的,我们将讨论 Draft.js 的格式。
|
||||
|
||||
Draft.js 只是富文本格式的一个例子,有很多可供选择。编辑器状态类似于抽象语法树,可以被序列化成 JSON 字符串进行存储。使用流行的富文本格式的好处是,您不必编写自定义解析代码,并且将来可以轻松扩展到更多类型的富文本实体。但是,这些格式往往比自定义语法版本更长的字符串,并且会导致更大的网络负载大小,并且需要更多的磁盘空间来存储。
|
||||
|
||||
以下示例说明了如何在 Draft.js 中表示上述帖子。
|
||||
|
||||
```js
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'HASHTAG',
|
||||
content: '#AboutLastNight',
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
content: ' is here... and ready to change ... Dropping 2/10 on ',
|
||||
},
|
||||
{
|
||||
type: 'MENTION',
|
||||
content: 'HBO Max',
|
||||
entityID: 1234,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
content: '!',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 渲染图片
|
||||
|
||||
由于 feed 帖子中可能包含图像,我们还可以简要讨论一些图像优化技术:
|
||||
|
||||
* **内容分发网络 (CDN)**:使用 (CDN) 来托管和提供图像,以实现更快的加载性能。
|
||||
* **现代图像格式**:使用现代图像格式,例如 [WebP](https://developers.google.com/speed/webp),它提供卓越的无损和有损图像压缩。
|
||||
* **`<img>` 应该使用适当的 `alt` 文本**
|
||||
* Facebook 通过使用机器学习和计算机视觉来处理图像并生成描述,从而为用户上传的图像提供 `alt` 文本。
|
||||
* 生成式 AI 模型如今在这方面也做得很好。
|
||||
* 基于设备屏幕属性的图像加载
|
||||
* 在 feed 列表请求中发送浏览器尺寸,以便服务器可以决定返回什么图像大小。
|
||||
* 如果有图像处理(调整大小)功能,请使用 `srcset` 加载最适合当前视口的图像文件。
|
||||
* 基于网络速度的自适应图像加载
|
||||
* **互联网连接良好/在 WiFi 上运行的设备**:预取尚未进入视口但即将进入视口的屏幕外图像。
|
||||
* **互联网连接较差**:渲染低分辨率的占位符图像,并要求用户明确单击它们以加载高分辨率图像。
|
||||
|
||||
#### 懒加载初始渲染不需要的代码
|
||||
|
||||
初始渲染不需要与 feed 帖子进行许多交互:
|
||||
|
||||
* 反应弹出窗口。
|
||||
* 由右上角省略号图标按钮显示的下拉菜单,该按钮通常用于隐藏其他操作。
|
||||
|
||||
这些组件的代码可以在以下情况下下载:
|
||||
|
||||
* 浏览器作为较低优先级的任务处于空闲状态。
|
||||
* 根据需要,当用户将鼠标悬停在按钮上或单击它们时。
|
||||
|
||||
根据 Facebook 以上的层级定义,这些被认为是第 3 层依赖项。
|
||||
|
||||
#### 乐观更新
|
||||
|
||||
乐观更新是一种性能技术,客户端在用户交互后立即反映更新后的状态,该交互会命中服务器,并乐观地假设服务器请求成功,对于大多数请求都应该如此。这使用户可以获得即时反馈并提高感知性能。如果服务器请求失败,我们可以恢复 UI 更改并显示错误消息。
|
||||
|
||||
对于新闻 feed,乐观更新可以通过立即显示用户的反应和更新的反应总数来应用于反应交互。
|
||||
|
||||
乐观更新是内置于现代查询库中的一项强大功能,例如 [Relay](https://relay.dev/docs/guided-tour/updating-data/graphql-mutations/#optimistic-updates)、[SWR](https://swr.vercel.app/docs/mutation#optimistic-updates) 和 [React Query](https://tanstack.com/query/v4/docs/guides/optimistic-updates)。
|
||||
|
||||
#### 时间戳渲染
|
||||
|
||||
由于一些问题,时间戳渲染是一个值得讨论的话题:多语言时间戳和过时的相对时间戳。
|
||||
|
||||
**多语言时间戳**:像 Facebook 和 Twitter 这样在全球流行的网站必须确保其 UI 适用于不同的语言。有几种方法可以支持多语言时间戳:
|
||||
|
||||
1. **服务器返回原始时间戳**:服务器返回原始时间戳,客户端以用户的语言呈现。这种方法很灵活,但需要客户端包含不同语言的语法规则和翻译字符串,这可能导致大量的 JavaScript 大小,具体取决于支持的语言数量,
|
||||
2. **服务器返回翻译后的时间戳**:这需要在服务器上进行处理,但您不必将各种语言的时间戳格式化规则发送给客户端。但是,由于翻译是在服务器上完成的,因此客户端无法在客户端操作时间戳。
|
||||
3. **`Intl` API**:现代浏览器可以利用 `Intl.DateTimeFormat()` 和 `Intl.RelativeTimeFormat()` 将原始时间戳转换为所需格式的翻译日期时间字符串。
|
||||
|
||||
```js
|
||||
const date = new Date(Date.UTC(2021, 11, 20, 3, 23, 16, 738));
|
||||
console.log(
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'long',
|
||||
}).format(date),
|
||||
); // 2021年12月20日星期一 GMT+8 11:23:16
|
||||
|
||||
console.log(
|
||||
new Intl.RelativeTimeFormat('zh-CN', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'long',
|
||||
}).format(-1, 'day'),
|
||||
); // 1天前
|
||||
```
|
||||
|
||||
**相对时间戳可能会过时**:如果时间戳使用相对格式显示(例如,3 分钟前、1 小时前、2 周前等),最近的时间戳很容易过时,尤其是在用户不刷新页面的应用程序中。如果时间戳是最近的(不到一小时),则可以使用计时器不断更新时间戳,以便正确反映任何已过去的重要时间。
|
||||
|
||||
#### 图标渲染
|
||||
|
||||
帖子操作按钮(如点赞、评论、分享等)中需要图标。 有几种渲染图标的方法:
|
||||
|
||||
| 方法 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| 分开的图片 | 易于实现。 | 每个图像需要多个下载请求。 |
|
||||
| 精灵图 | 一个 HTTP 请求下载所有图标图像。 | 设置复杂。 |
|
||||
| 图标字体 | 可扩展且清晰。 | 需要下载整个字体。 加载字体时出现未样式化的内容。 |
|
||||
| SVG | 可扩展且清晰。 可缓存。 | 下载文件时闪烁。 每个图像需要一个下载请求。 |
|
||||
| 内联 SVG | 可扩展且清晰。 | 无法缓存。 |
|
||||
|
||||
Facebook 和 Twitter 使用内联 SVG,这似乎也是当今的趋势。 这种技术并非特定于新闻提要,它与几乎每个 Web 应用程序都相关。
|
||||
|
||||
*来源:[“为新的 Facebook.com 重建我们的技术堆栈”博客文章](https://engineering.fb.com/2020/05/08/web/facebook-redesign/)*
|
||||
|
||||
#### 帖子截断
|
||||
|
||||
截断消息内容超长的帖子,并在“查看更多”按钮后面显示其余内容。
|
||||
|
||||
对于活动量大的帖子(例如,许多点赞、反应、分享),适当地缩写计数,而不是渲染原始计数,以便于阅读,并且仍然充分传达数量级:
|
||||
|
||||
* **好**:John、Mary 和其他 103K 人
|
||||
* **坏**:John、Mary 和其他 103,312 人
|
||||
|
||||
此摘要行可以在服务器或客户端上构建。 在服务器上与客户端上执行的优缺点与时间戳渲染的优缺点类似。 但是,如果用户列表很大,则绝对不应发送整个用户列表,因为它可能不需要或无用。
|
||||
|
||||
#### Feed 评论
|
||||
|
||||
如果时间允许,我们可以讨论如何构建 Feed 评论。 总的来说,同样的规则也适用于评论渲染和评论草稿:
|
||||
|
||||
* 基于游标的分页,用于获取评论列表。
|
||||
* 草拟和编辑评论可以以类似于草拟/编辑帖子的方式完成。
|
||||
* 在评论输入中延迟加载表情符号/贴纸选择器。
|
||||
* 乐观更新
|
||||
* 通过将用户的新评论附加到现有的评论列表中,立即反映新评论。
|
||||
* 立即显示新的反应和更新的反应计数。
|
||||
|
||||
#### 实时评论更新
|
||||
|
||||
Facebook Feed 评论的实时更新通过提供新评论和更新的反应计数的实时可见性来增强用户参与度和互动。 这营造了一个动态和响应迅速的沟通环境,鼓励用户积极参与正在进行的对话,而无需手动刷新。 实时更新的即时性有助于提高用户保留率。
|
||||
|
||||
在客户端上实现实时更新的常用方法包括:
|
||||
|
||||
* **短轮询**:短轮询是一种技术,客户端以固定的间隔重复向服务器发送请求以检查更新。 每次请求后连接都会关闭,服务器会立即响应当前状态或任何可用的更新。 虽然短轮询易于实现,但与下面提到的更高级的技术相比,它可能会导致更高的网络流量和服务器负载。
|
||||
* **长轮询**:长轮询通过保持连接打开直到有新数据可用,从而扩展了短轮询的想法。 虽然实现起来更简单,但与其他方法相比,它可能会引入延迟并增加服务器负载。
|
||||
* **服务器发送事件 (SSE)**:SSE 是一种标准 Web 技术,它使服务器能够通过单个 HTTP 连接将更新推送到 Web 客户端。 这是一个简单有效的实时更新机制,特别适用于服务器启动更新的场景。
|
||||
* **WebSockets**:WebSockets 通过单个、长连接提供全双工通信通道。 这种双向通信允许服务器和客户端随时互相发送消息。 WebSockets 适用于需要低延迟和高交互性的应用程序。
|
||||
* **HTTP/2 服务器推送**:使用 HTTP/2,服务器可以将更新推送到客户端,而无需等待客户端请求它们。 虽然 HTTP/2 服务器推送不像其他实时更新方法那样被广泛使用,但在某些情况下,它可能是一种有效的解决方案。
|
||||
|
||||
Facebook 在网站上使用 WebSockets 进行实时更新。
|
||||
|
||||
虽然显示实时更新很棒,但获取 Feed 中已消失的帖子的更新效率不高。 客户端可以根据帖子是否可见来订阅/取消订阅帖子的更新,这减轻了服务器基础设施的负载。
|
||||
|
||||
此外,并非所有帖子都应一视同仁。拥有众多关注者(例如,名人和政治家)的用户的帖子将被更多人看到,因此更有可能收到更新。对于此类帖子,新的评论和反应将被频繁添加/更新,获取每个新帖子或反应是不明智的,因为更新频率对于用户来说太高,无法阅读每个新评论。因此,对于此类帖子,可以对实时更新进行去抖动/节流。超过某个阈值后,仅获取更新的评论和反应计数就足够了。
|
||||
|
||||
### Feed composer 优化
|
||||
|
||||
#### 标签和提及的富文本
|
||||
|
||||
在撰写帖子时,拥有一个类似结果的 WYSIWYG 编辑体验,其中包含标签和提及,会很好。但是,`<input>` 和 `<textarea>` 仅允许输入和显示纯文本。[`contenteditable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable) 属性将元素转换为可编辑的富文本编辑器。
|
||||
|
||||
在这里亲自尝试一下:
|
||||
|
||||
<div style={{ border: '1px solid #7773', borderRadius: 8, padding: '0 16px' }} contentEditable={true} suppressContentEditableWarning={true}>
|
||||
<strong>可编辑和可格式化</strong> 文本,感谢{' '}
|
||||
<code>contenteditable</code>。您甚至可以格式化文本(例如,使用 Ctrl/Cmd + B 加粗)。
|
||||
</div>
|
||||
|
||||
但是,在生产中使用 `contenteditable="true"` 并不是一个好主意,因为它 [存在许多问题](https://medium.engineering/why-contenteditable-is-terrible-122d8a40e480?gi=6b03d73f8e36)。最好使用经过实战检验的富文本编辑器库。
|
||||
|
||||
Meta 构建了富文本编辑器,例如 [Draft.js](https://draftjs.org)(已弃用)和 [Lexical](https://lexical.dev),并将它们用于起草 + 显示帖子和评论。其他流行的开源替代方案包括 [TipTap](https://tiptap.dev/api/editor) 和 [Slate](https://www.slatejs.org/)。
|
||||
|
||||
*来源:[Facebook 开源富文本编辑器框架 Draft.js](https://engineering.fb.com/2016/02/26/web/facebook-open-sources-rich-text-editor-framework-draft-js/)*
|
||||
|
||||
#### 延迟加载依赖项
|
||||
|
||||
与渲染新闻 feed 帖子一样,用户可以以许多不同的格式起草帖子,这需要每种格式的专用渲染代码。可以使用惰性加载来按需加载所需格式和可选功能的资源。
|
||||
|
||||
可以按需进行惰性加载的代码的非关键功能:
|
||||
|
||||
* 图像上传器
|
||||
* GIF 选择器
|
||||
* 表情符号选择器
|
||||
* 贴纸选择器
|
||||
* 背景图片
|
||||
|
||||
### 可访问性
|
||||
|
||||
以下是新闻 feed 的一些可访问性注意事项。
|
||||
|
||||
#### Feed 列表
|
||||
|
||||
* 将 `role="feed"` 添加到 feed HTML 元素。
|
||||
|
||||
#### Feed 帖子
|
||||
|
||||
* 将 `role="article"` 添加到每个 feed 帖子 HTML 元素。
|
||||
* `aria-labelledby="<id>"`,其中包含 feed 作者名称的 HTML 标签具有该 `id` 属性。
|
||||
* feed 帖子中的内容应可通过键盘聚焦(添加 `tabindex="0"`)和适当的 `aria-role`。
|
||||
|
||||
#### Feed 交互
|
||||
|
||||
* 在 Facebook 网站上,用户可以通过将鼠标悬停在“赞”按钮上来获得更多反应选项。为了允许键盘用户使用相同的功能,Facebook 显示了一个仅在聚焦时出现的按钮,并且可以通过该按钮打开反应菜单。
|
||||
* 仅限图标的按钮如果没有附带标签,则应具有 `aria-label`(例如 Twitter)。
|
||||
|
||||
***
|
||||
|
||||
## 参考资料
|
||||
|
||||
* [为新的 Facebook.com 重建我们的技术栈](https://engineering.fb.com/2020/05/08/web/facebook-redesign/)
|
||||
* [让 Facebook.com 尽可能多的人可以使用](https://engineering.fb.com/2020/07/30/web/facebook-com-accessibility/)
|
||||
* [我们如何构建 Twitter Lite](https://blog.twitter.com/engineering/en_us/topics/open-source/2017/how-we-built-twitter-lite)
|
||||
* [构建新的 Twitter.com](https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/buildingthenewtwitter)
|
||||
* [剖析 Twitter 的 Redux 存储](https://medium.com/statuscode/dissecting-twitters-redux-store-d7280b62c6b1)
|
||||
* [Twitter Lite 和大规模高性能 React 渐进式 Web 应用程序](https://medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-scale-d28a00e780a3)
|
||||
* [让 Instagram.com 速度更快:第 1 部分](https://instagram-engineering.com/making-instagram-com-faster-part-1-62cc0c327538)
|
||||
* [让 Instagram.com 速度更快:第 2 部分](https://instagram-engineering.com/making-instagram-com-faster-part-2-f350c8fba0d4)
|
||||
* [让 Instagram.com 速度更快:第 3 部分 — 缓存优先](https://instagram-engineering.com/making-instagram-com-faster-part-3-cache-first-6f3f130b9669)
|
||||
* [在 Slack 上演进 API 分页](https://slack.engineering/evolving-api-pagination-at-slack)
|
||||
|
||||
## 更新日志
|
||||
|
||||
* 2024/08/21
|
||||
* 为有效负载响应添加了更多 `image` 字段的属性
|
||||
* 提到了 `Intl.RelativeTimeFormat` API
|
||||
* 2023/12/04
|
||||
* 增加了关于实时评论的部分
|
||||
|
||||
{/* TODO: 讨论是否使用渐进式 Web 应用程序 */}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "2580f882",
|
||||
"excerpt": "7b04fa"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"ba1df770",
|
||||
"46c42859",
|
||||
"9ec6d64c",
|
||||
"c4b2c20"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"ba1df770",
|
||||
"46c42859",
|
||||
"9ec6d64c",
|
||||
"c4b2c20"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: 照片分享(例如 Instagram)
|
||||
excerpt: 设计一个类似 Instagram 的照片分享应用程序
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个照片分享应用程序,其中包含用户创建的照片帖子的列表。用户可以创建包含照片的新帖子。
|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* https://www.instagram.com
|
||||
* https://www.flickr.com
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"e1bb79f5",
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"fd18a502",
|
||||
"2f6c4f8d",
|
||||
"5ed20915",
|
||||
"7f7027a8",
|
||||
"ee7d6518",
|
||||
"cc56802d",
|
||||
"10a72a7",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"bc3ae319",
|
||||
"eb179c2f",
|
||||
"a9ba4d9a",
|
||||
"da1aa986",
|
||||
"6bb51a4",
|
||||
"98d3fe28",
|
||||
"74f49c93",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"76f116e0",
|
||||
"ac25a587",
|
||||
"6aec793d",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"8764ae1",
|
||||
"4771acf9",
|
||||
"64827262",
|
||||
"a311fcc8",
|
||||
"5ece3f2c",
|
||||
"6a9045c6",
|
||||
"788c7882",
|
||||
"3a22707c",
|
||||
"5674c99c",
|
||||
"a59f947b",
|
||||
"d89a9b94",
|
||||
"f3f14098",
|
||||
"674bf050",
|
||||
"a9e0cad7",
|
||||
"365e525d",
|
||||
"f92a369f",
|
||||
"d89a9b94",
|
||||
"77f8af16",
|
||||
"674bf050",
|
||||
"a1871af",
|
||||
"eb978b35",
|
||||
"46be22ee",
|
||||
"e265ab5a",
|
||||
"84fb1fa5",
|
||||
"5bd92c59",
|
||||
"707a7f58",
|
||||
"d025c504",
|
||||
"89aaec27",
|
||||
"799e42c9",
|
||||
"17f2aa8c",
|
||||
"7bb2e5c5",
|
||||
"ad18dc54",
|
||||
"3588b481",
|
||||
"b53f54fa",
|
||||
"7e2564ef",
|
||||
"2bda0ae4",
|
||||
"4541685f",
|
||||
"f7b4b0b8",
|
||||
"69a440d6",
|
||||
"721ace1d",
|
||||
"9a3d53be",
|
||||
"78b22fba",
|
||||
"4718ee9c",
|
||||
"e7bb7378",
|
||||
"5ebbb865",
|
||||
"c8b62787",
|
||||
"6afeeca9",
|
||||
"6af6dd32",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"fbf9d231"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"e1bb79f5",
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"fd18a502",
|
||||
"2f6c4f8d",
|
||||
"5ed20915",
|
||||
"7f7027a8",
|
||||
"ee7d6518",
|
||||
"cc56802d",
|
||||
"10a72a7",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"bc3ae319",
|
||||
"eb179c2f",
|
||||
"a9ba4d9a",
|
||||
"da1aa986",
|
||||
"6bb51a4",
|
||||
"98d3fe28",
|
||||
"74f49c93",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"76f116e0",
|
||||
"ac25a587",
|
||||
"6aec793d",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"8764ae1",
|
||||
"4771acf9",
|
||||
"64827262",
|
||||
"a311fcc8",
|
||||
"5ece3f2c",
|
||||
"6a9045c6",
|
||||
"788c7882",
|
||||
"3a22707c",
|
||||
"5674c99c",
|
||||
"a59f947b",
|
||||
"d89a9b94",
|
||||
"f3f14098",
|
||||
"674bf050",
|
||||
"a9e0cad7",
|
||||
"365e525d",
|
||||
"f92a369f",
|
||||
"d89a9b94",
|
||||
"77f8af16",
|
||||
"674bf050",
|
||||
"a1871af",
|
||||
"eb978b35",
|
||||
"46be22ee",
|
||||
"e265ab5a",
|
||||
"84fb1fa5",
|
||||
"5bd92c59",
|
||||
"707a7f58",
|
||||
"d025c504",
|
||||
"89aaec27",
|
||||
"799e42c9",
|
||||
"17f2aa8c",
|
||||
"7bb2e5c5",
|
||||
"ad18dc54",
|
||||
"3588b481",
|
||||
"b53f54fa",
|
||||
"7e2564ef",
|
||||
"2bda0ae4",
|
||||
"4541685f",
|
||||
"f7b4b0b8",
|
||||
"69a440d6",
|
||||
"721ace1d",
|
||||
"9a3d53be",
|
||||
"78b22fba",
|
||||
"4718ee9c",
|
||||
"e7bb7378",
|
||||
"5ebbb865",
|
||||
"c8b62787",
|
||||
"6afeeca9",
|
||||
"6af6dd32",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"fbf9d231"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
**注意**:此应用程序与[新闻提要系统设计](/questions/system-design/news-feed-facebook)应用程序有很多相似之处,并且提要帖子中的图像轮播将受益于[图像轮播系统设计](/questions/system-design/image-carousel)。请在开始之前阅读该问题。对于这个问题,我们将重点关注尚未涵盖的内容,讨论将围绕照片和图像展开。
|
||||
|
||||
## 需求探索
|
||||
|
||||
### 应该支持哪些核心功能?
|
||||
|
||||
* 浏览包含用户关注的人的图片和视频帖子的 feed。
|
||||
* 上传照片,添加标题,并在发布前对其应用滤镜。
|
||||
|
||||
### feed 应该使用什么分页 UX?
|
||||
|
||||
无限滚动,这意味着当用户到达 feed 的末尾时,将添加更多帖子。
|
||||
|
||||
### 用户是否能够向单个照片/图像添加标题,或者只能向帖子添加一个总标题?
|
||||
|
||||
仅向帖子添加一个总标题。
|
||||
|
||||
### 应用程序将在哪些设备上使用?
|
||||
|
||||
主要用于移动设备,但也应在桌面和平板电脑上使用。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
### 渲染方法
|
||||
|
||||
照片共享应用程序具有以下特征:
|
||||
|
||||
* 未登录用户可以查看帖子。
|
||||
* 帖子可以通过搜索引擎搜索。
|
||||
* 由于帖子撰写和对帖子的点赞/评论功能,应用程序的交互性很强。
|
||||
* 需要快速的初始加载速度。
|
||||
|
||||
这些也是[新闻提要系统设计](/questions/system-design/news-feed-facebook)的特征,其中有大量静态内容也需要交互。因此,结合服务器端渲染和水合以及后续的客户端渲染将是理想的选择。实际上,instagram.com 是使用与 facebook.com 相同的技术堆栈构建的,该堆栈使用带有水合的 React 服务器端渲染。
|
||||
|
||||
### 架构图
|
||||
|
||||

|
||||
|
||||
* **服务器**:提供 HTTP API 以获取 feed 帖子、上传图像以及创建新的图像帖子。
|
||||
* **控制器**:控制应用程序内的数据流并向服务器发出网络请求。
|
||||
* **客户端存储**:存储整个应用程序所需的数据。在照片共享应用程序的上下文中,存储中的大多数数据将是 feed UI 所需的服务器生成的数据。
|
||||
* **Feed UI**:包含图像帖子列表和用于创建新帖子的 UI。
|
||||
* **图像帖子**:包含一个或多个图像的帖子列表。
|
||||
* **帖子撰写器**:用于上传图像、对其应用滤镜以及在提交到服务器之前添加标题的 UI。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
照片 Feed 显示从服务器获取的图像帖子的列表,因此应用程序中涉及的大部分数据将是服务器生成的数据。 唯一需要的客户端数据是用户上传的图像和用户在帖子撰写器中编写的标题。
|
||||
|
||||
| 实体 | 来源 | 属于 | 字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Feed` | 服务器 | Feed UI | `posts` ( `Post` 列表), `pagination` (分页元数据) |
|
||||
| `Post` | 服务器 | Feed Post | `id`, `created_time`, `caption`, `image`, `author` (一个 `User`), `images` |
|
||||
| `User` | 服务器 | 客户端存储 | `id`, `name`, `profile_photo_url` |
|
||||
| `NewPost` | 用户输入 (客户端) | 帖子撰写器 UI | `caption`, `images` (`Image`) |
|
||||
| `Image` | 服务器/客户端 | 多个 | `url`, `alt`, `width`, `height` |
|
||||
|
||||
就像在 [新闻 Feed 系统设计](/questions/system-design/news-feed-facebook) 中一样,规范化的客户端存储对于照片共享应用程序也很有用。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
### Feed 列表 API
|
||||
|
||||
与 [新闻 Feed 的](/questions/system-design/news-feed-facebook) 列表 API 一样,照片共享应用程序的 Feed 列表 API 也应该使用**基于游标的分页方法**。
|
||||
|
||||
### Post creation API
|
||||
|
||||
照片共享应用程序中的所有帖子都附有图像。 因此,此类应用程序中的帖子创建过程更加复杂。
|
||||
|
||||
让我们将帖子创建分解为几个步骤:
|
||||
|
||||
1. 用户从他们的设备中选择照片。
|
||||
2. 用户能够对他们的照片执行轻量级编辑:调整大小/裁剪/滤镜。
|
||||
3. 用户为帖子添加标题。
|
||||
4. 用户提交帖子。
|
||||
|
||||
有两种常见的方法来实现涉及图像的帖子创建功能:
|
||||
|
||||
1. 为图像上传和帖子创建创建一个 API。
|
||||
2. 为图像上传和帖子创建创建单独的 API。
|
||||
|
||||
#### 1. 为图像上传和帖子创建创建一个 API
|
||||
|
||||

|
||||
|
||||
优点:
|
||||
|
||||
* 易于实现,在一个请求中上传所有必需的数据。
|
||||
|
||||
缺点:
|
||||
|
||||
* API 逻辑更复杂,因为它必须在后端执行多项操作(尽管这实际上不是前端所关心的)。
|
||||
* 上传将需要更长的时间,因为帖子图像必须在一个请求中上传。
|
||||
* API 必须同时接收基于文本的数据和媒体数据。
|
||||
|
||||
#### 2. 为图像上传和帖子创建创建单独的 API
|
||||
|
||||

|
||||
|
||||
优点:
|
||||
|
||||
* 图像上传阶段可以异步完成。 可以在用户起草帖子标题时,在后台的图像编辑阶段之后上传图像。
|
||||
* 图像可以跨多个 HTTP 请求并行上传,这比在单个 HTTP 请求中上传所有图像要快,后者将花费更长的时间。
|
||||
* 如果有的话,可以利用现有的通用图像上传 API。
|
||||
|
||||
缺点:
|
||||
|
||||
* 客户端需要更多的协调。 客户端需要等待所有图像上传完毕,并获取对上传图像的引用,并在创建帖子时使用它们。
|
||||
|
||||
### 哪种方法更好?
|
||||
|
||||
比较这两种方法,单独的 API 方法会更好。
|
||||
|
||||
* **可重用**:图像上传 API 可以是一个通用的 API,可以在整个网站中使用,而不仅仅用于帖子创建流程(例如,个人资料图片上传)。
|
||||
* **减少带宽**:实际上,像 Amazon S3 和 Cloudflare R2 这样的 Blob 存储解决方案用于存储静态资产,如图像。 客户端可以请求预签名 URL 并直接上传到存储桶,而无需通过应用程序服务器,这可以减少带宽,从而降低服务器成本。
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### Feed 优化
|
||||
|
||||
在 [新闻 Feed 系统设计](/questions/system-design/news-feed-facebook) 中完成的这些优化也适用于照片共享应用程序的 Feed。
|
||||
|
||||
* 渲染方法
|
||||
* 无限滚动实现
|
||||
* 虚拟化列表
|
||||
* 代码拆分 JavaScript
|
||||
* 加载指示器
|
||||
* 保留 Feed 滚动位置
|
||||
* 延迟加载代码
|
||||
* 乐观更新
|
||||
* 时间戳渲染
|
||||
* 图标渲染
|
||||
|
||||
### 图像轮播优化
|
||||
|
||||
帖子中的图像显示为轮播,因此 [图像轮播系统设计文章](/questions/system-design/image-carousel) 对这个问题也很有用,并且也适用相同的优化。
|
||||
|
||||
### 渲染图像
|
||||
|
||||
* 使用现代图像格式,例如 [WebP](https://developers.google.com/speed/webp),它提供卓越的无损和有损图像压缩。
|
||||
* `<img>` 应该使用适当的 `alt` 文本。
|
||||
* Instagram 允许用户为每张图片提供 `alt` 文本。 如果用户未指定 `alt` 文本,则可以使用机器学习和计算机视觉技术来处理图像并生成描述。
|
||||
* 基于设备屏幕属性的图像加载
|
||||
* 在初始请求(或后续请求也可以)中发送浏览器尺寸,服务器可以决定返回什么图像大小。
|
||||
* 如果有图像处理(调整大小)功能,请使用 `srcset` 加载最适合当前视口的图像文件。
|
||||
* 基于网络速度的自适应图像加载
|
||||
* **具有良好互联网连接/在 WiFi 上运行的设备**:预取尚未进入视口但即将进入视口的屏幕外图像。
|
||||
* **互联网连接较差**:渲染低分辨率占位符图像,并要求用户明确单击它们以加载高分辨率图像。
|
||||
|
||||
### 图像编辑
|
||||
|
||||
#### 裁剪和调整大小
|
||||
|
||||
图像的裁剪和调整大小可以通过 HTML5 的 `<canvas>` 完成。
|
||||
|
||||
```js
|
||||
const canvas = document.getElementById('image-editor');
|
||||
const context = canvas.getContext('2d');
|
||||
const image = new Image();
|
||||
image.src = 'https://greatimages.com/example.jpg';
|
||||
context.drawImage(image /* Other parameters */);
|
||||
```
|
||||
|
||||
Instagram 网站允许通过模拟结果并使用内联样式来修改图像的变换样式和定位来进行编辑。
|
||||
|
||||
#### 滤镜
|
||||
|
||||
CSS 提供了一个 [`filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/filter) 属性,该属性允许应用滤镜功能,例如 `blur`、`contrast`、`hue`、`sepia` 等。 通过结合使用这些滤镜功能,您可以在浏览器中实现类似 Instagram 的滤镜功能。
|
||||
|
||||
以下是实现 Instagram 的 Clarendon 和 Gingham 滤镜效果的滤镜函数示例,取自很棒的 [Instagram.css](https://picturepan2.github.io/instagram.css/)。
|
||||
|
||||
```css
|
||||
.filter-1977 {
|
||||
filter: sepia(0.5) hue-rotate(-30deg) saturate(1.4);
|
||||
}
|
||||
|
||||
.filter-brannan {
|
||||
filter: sepia(0.4) contrast(1.25) brightness(1.1) saturate(0.9) hue-rotate(-2deg);
|
||||
}
|
||||
```
|
||||
|
||||
以下图像使用这些 CSS 滤镜在您的浏览器中增强。
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>原始</th>
|
||||
<th>1977</th>
|
||||
<th>Brannan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Air Balloon"
|
||||
src="/img/questions/photo-sharing-instagram/photo-sharing-air-balloon.jpg"
|
||||
width={150}
|
||||
style={{
|
||||
margin: '0 !important',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<img
|
||||
alt="Air Balloon with 1977 filter effect"
|
||||
src="/img/questions/photo-sharing-instagram/photo-sharing-air-balloon.jpg"
|
||||
width={150}
|
||||
style={{
|
||||
filter: 'sepia(0.5) hue-rotate(-30deg) saturate(1.4)',
|
||||
margin: '0 !important',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<img
|
||||
alt="Air Balloon with Brannan filter effect"
|
||||
src="/img/questions/photo-sharing-instagram/photo-sharing-air-balloon.jpg"
|
||||
width={150}
|
||||
style={{
|
||||
filter:
|
||||
'sepia(0.4) contrast(1.25) brightness(1.1) saturate(0.9) hue-rotate(-2deg)',
|
||||
margin: '0 !important',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
最终,最终图像仍然必须转换为图像 blob 才能发送到服务器,并且可以使用 [html2canvas](https://github.com/niklasvh/html2canvas) 等库来完成此转换。
|
||||
|
||||
### 辅助功能 (a11y)
|
||||
|
||||
#### 屏幕阅读器
|
||||
|
||||
* `<img>` 标签应具有指定的有意义的 `alt` 描述或使用空字符串。
|
||||
* 在图片轮播中的上一个/下一个按钮中添加 `aria-label`。
|
||||
* 仅限图标的按钮如果没有任何附带标签(例如,心形、共享、书签),则应具有适当的 `aria-label`。
|
||||
|
||||
#### 键盘支持
|
||||
|
||||
* 尽可能对上一个/下一个按钮使用 `<button>` HTML 标签,以便按钮可以聚焦。
|
||||
* 添加 `<div role="region" aria-label="Image Carousel" tabindex="0">` 以使轮播可聚焦,并附加 Left/Right keydown 处理程序以允许使用键盘滚动浏览图像。
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
* [让 Instagram.com 速度更快:第 1 部分](https://instagram-engineering.com/making-instagram-com-faster-part-1-62cc0c327538)
|
||||
* [让 Instagram.com 速度更快:第 2 部分](https://instagram-engineering.com/making-instagram-com-faster-part-2-f350c8fba0d4)
|
||||
* [让 Instagram.com 速度更快:第 3 部分 — 缓存优先](https://instagram-engineering.com/making-instagram-com-faster-part-3-cache-first-6f3f130b9669)
|
||||
* [让 instagram.com 速度更快:代码大小和执行优化(第 4 部分)](https://instagram-engineering.com/making-instagram-com-faster-code-size-and-execution-optimizations-part-4-57668be796a8)
|
||||
* [在桌面上启动 Instagram 消息](https://about.instagram.com/blog/engineering/launching-instagram-messaging-on-desktop)
|
||||
* [制作可访问的 Instagram Feed](https://about.instagram.com/blog/engineering/crafting-an-accessible-instagram-feed)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "2edddd8c",
|
||||
"excerpt": "78adc7f4"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"e4541f25",
|
||||
"e7d14d74",
|
||||
"6f97f7ff",
|
||||
"6406db9b",
|
||||
"6d677549",
|
||||
"985c8dee",
|
||||
"c2808f5b",
|
||||
"ff28234f",
|
||||
"f89a8a8"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"e4541f25",
|
||||
"e7d14d74",
|
||||
"6f97f7ff",
|
||||
"6406db9b",
|
||||
"6d677549",
|
||||
"985c8dee",
|
||||
"c2808f5b",
|
||||
"ff28234f",
|
||||
"f89a8a8"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
title: Pinterest
|
||||
excerpt: 设计 Pinterest 主页,重点关注瀑布流布局
|
||||
---
|
||||
|
||||
瀑布流布局是一种流行且灵活的基于网格的布局设计。与传统网格不同,传统网格中同一行的元素具有相同的高度,而元素放置在列中,但可以具有不同的高度,从而产生更具视觉趣味的排列。
|
||||
|
||||
这种布局有效地利用了空间,因为它用其内容填充了大部分可用屏幕空间,并且元素之间的空白空间很少。它通常用于呈现用户生成的内容,如图像和 GIF。拥有这种布局的最著名的网站是 [Pinterest](https://pinterest.com/)。
|
||||
|
||||
## 问题
|
||||
|
||||
设计 [Pinterest 主页](https://pinterest.com/),重点关注瀑布流布局。
|
||||
|
||||

|
||||
|
||||
Pinterest 前端系统设计问题通常以两种方式提出:
|
||||
|
||||
1. 设计 Pinterest 主页,涵盖页面架构、瀑布流布局、数据获取等。
|
||||
2. 设计一个瀑布流组件,讨论其属性、布局方法等。
|
||||
|
||||
我们将重点关注前者,但也会为后者提供足够的内容和指导。事实上,Pinterest 在其主页上使用的实际瀑布流组件是用 [React 构建并开源的](https://gestalt.pinterest.systems/web/masonry)!您可以深入研究源代码,了解该组件的来龙去脉,也可以在您自己的网站上使用它。
|
||||
|
||||
**注意**:Pinterest 本质上是一个具有多列布局的图片 feed。因此,它与 [新闻 feed 系统设计](/questions/system-design/news-feed-facebook) 和 [Instagram 系统设计](/questions/system-design/photo-sharing-instagram) 有很多相似之处。在开始之前,请阅读这些问题。对于这个问题,讨论将围绕瀑布流布局,而不是一般的 feed 优化。
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"2326ad54",
|
||||
"70c16ada",
|
||||
"bbb9bfed",
|
||||
"4f7edd69",
|
||||
"c814ea93",
|
||||
"cc56802d",
|
||||
"4056d554",
|
||||
"2a7816d0",
|
||||
"f4a3d1c0",
|
||||
"864d4d15",
|
||||
"3cce7975",
|
||||
"eb64fe1b",
|
||||
"2baabaf6",
|
||||
"c14ae344",
|
||||
"5df0cd3a",
|
||||
"f8e74859",
|
||||
"3588bcb2",
|
||||
"91d66590",
|
||||
"c9689845",
|
||||
"8297620e",
|
||||
"e2f4d782",
|
||||
"b442f99",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"e186082a",
|
||||
"f8a6877a",
|
||||
"bf35dddd",
|
||||
"907fd70",
|
||||
"f2fd6dea",
|
||||
"b1155e67",
|
||||
"d8f188ac",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"c7e68a93",
|
||||
"ab8b2c0d",
|
||||
"5929534c",
|
||||
"cb2fa491",
|
||||
"e5dad405",
|
||||
"65a17d53",
|
||||
"616bb2fc",
|
||||
"e0205b0d",
|
||||
"c5717a31",
|
||||
"ab103065",
|
||||
"db395b9f",
|
||||
"b603bf16",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"4af8ceb3",
|
||||
"93dde9ce",
|
||||
"6d52d39",
|
||||
"92a7cb38",
|
||||
"26bda54a",
|
||||
"d8853cd0",
|
||||
"199ed625",
|
||||
"e5aa7268",
|
||||
"3e18b5e3",
|
||||
"b80bc546",
|
||||
"1ac6085d",
|
||||
"8f2a94ed",
|
||||
"74a73464",
|
||||
"e01afabb",
|
||||
"ecfa10b3",
|
||||
"72da2670",
|
||||
"b10c8083",
|
||||
"ec670bf9",
|
||||
"d6e1ee91",
|
||||
"5d6f828d",
|
||||
"8604e4d3",
|
||||
"1ca7e0e3",
|
||||
"7429a373",
|
||||
"6dce7bbe",
|
||||
"2c4984cd",
|
||||
"fdffcbfb",
|
||||
"e687b091",
|
||||
"f526345f",
|
||||
"4b1c3867",
|
||||
"8c1996de",
|
||||
"b68dae55",
|
||||
"a364b363",
|
||||
"560d5e17",
|
||||
"6b33fd4a",
|
||||
"4d01a1db",
|
||||
"5ac7cf38",
|
||||
"68d7af89",
|
||||
"fdc9ac97",
|
||||
"90d9dc51",
|
||||
"7b7a0648",
|
||||
"fb3dcb70",
|
||||
"424db01a",
|
||||
"b1540919",
|
||||
"800e8cd6",
|
||||
"f101cb2",
|
||||
"86e6ecb2",
|
||||
"fbecad5a",
|
||||
"b797b6ab",
|
||||
"8b23a5fc",
|
||||
"e660f78e",
|
||||
"2a7816d0",
|
||||
"dc890ef8",
|
||||
"78f70dfc",
|
||||
"c583b68f",
|
||||
"3e62ea2",
|
||||
"1d204fc",
|
||||
"bce9b0b6",
|
||||
"162542cf",
|
||||
"89456dbc",
|
||||
"43dc1b8d",
|
||||
"c47ce183",
|
||||
"6735000b",
|
||||
"893fdf50",
|
||||
"314a19b",
|
||||
"ffb2d0cc",
|
||||
"236b54d5",
|
||||
"c93228e0",
|
||||
"b0774285",
|
||||
"d5dc6c2e",
|
||||
"13983dc5",
|
||||
"b23d1ef1",
|
||||
"75d380b4",
|
||||
"7f9acfac",
|
||||
"a497a4b0",
|
||||
"6c35b5b2",
|
||||
"549329e1",
|
||||
"2f37a9b2",
|
||||
"d7f914f",
|
||||
"ed9bad0b",
|
||||
"8207dd95",
|
||||
"5e29ac40",
|
||||
"d33a4f22",
|
||||
"afd6aade",
|
||||
"7aeabff3",
|
||||
"a210eb36",
|
||||
"231835d0",
|
||||
"2ecb6f39",
|
||||
"69d2bf57",
|
||||
"d73d1671",
|
||||
"827cbbc6",
|
||||
"b388aff6",
|
||||
"50c49665",
|
||||
"77ab5113",
|
||||
"7791c47c",
|
||||
"4842a356",
|
||||
"bfb6a370",
|
||||
"916e5880",
|
||||
"d99c9f6e",
|
||||
"d1fffb27",
|
||||
"8f208955",
|
||||
"fd3c3739",
|
||||
"54ecbcd",
|
||||
"8fc32b40",
|
||||
"29d6c1e2",
|
||||
"b97ebba7",
|
||||
"21937cca",
|
||||
"2a643a6",
|
||||
"7e04b2e2",
|
||||
"3de8b559",
|
||||
"b630dc8f",
|
||||
"b1d166ad",
|
||||
"e84744af",
|
||||
"47cf33ba",
|
||||
"6c5ac796",
|
||||
"e2079509",
|
||||
"33664082",
|
||||
"9846f084",
|
||||
"5ead2d23",
|
||||
"65fb0b48",
|
||||
"c5767d80",
|
||||
"48832f93",
|
||||
"61cca83",
|
||||
"83086f95",
|
||||
"647eba1c",
|
||||
"36f5aef8",
|
||||
"d4607e44",
|
||||
"55f033c7",
|
||||
"595d82cf",
|
||||
"3c96d44",
|
||||
"5d471e85",
|
||||
"d9ed1bf5",
|
||||
"64175b25",
|
||||
"7f546314",
|
||||
"3b0e4326",
|
||||
"b7a93e12",
|
||||
"6fbce2a",
|
||||
"46147e8d",
|
||||
"ab09856b",
|
||||
"42df166c",
|
||||
"7a82cf46",
|
||||
"904215ef",
|
||||
"e5556fa5",
|
||||
"37e3aded",
|
||||
"786bbe16",
|
||||
"eeb2e39c",
|
||||
"be35f824",
|
||||
"10615db7",
|
||||
"ab34bd00",
|
||||
"b4fca2b1",
|
||||
"f4e52727",
|
||||
"d5ad3882",
|
||||
"a9a89d52",
|
||||
"f6fd3535",
|
||||
"7b47db03",
|
||||
"df29b84a",
|
||||
"d89a3a55",
|
||||
"e7bb7378",
|
||||
"5ebbb865",
|
||||
"d6702214",
|
||||
"6afeeca9",
|
||||
"f913c4e3",
|
||||
"6e54bd5d",
|
||||
"cb9b24f0",
|
||||
"90f4c629",
|
||||
"a2f31449",
|
||||
"62fd75b6",
|
||||
"5a4c7ed5"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"2326ad54",
|
||||
"70c16ada",
|
||||
"bbb9bfed",
|
||||
"4f7edd69",
|
||||
"c814ea93",
|
||||
"cc56802d",
|
||||
"4056d554",
|
||||
"2a7816d0",
|
||||
"f4a3d1c0",
|
||||
"864d4d15",
|
||||
"3cce7975",
|
||||
"eb64fe1b",
|
||||
"2baabaf6",
|
||||
"c14ae344",
|
||||
"5df0cd3a",
|
||||
"f8e74859",
|
||||
"3588bcb2",
|
||||
"91d66590",
|
||||
"c9689845",
|
||||
"8297620e",
|
||||
"e2f4d782",
|
||||
"b442f99",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"e186082a",
|
||||
"f8a6877a",
|
||||
"bf35dddd",
|
||||
"907fd70",
|
||||
"f2fd6dea",
|
||||
"b1155e67",
|
||||
"d8f188ac",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"c7e68a93",
|
||||
"ab8b2c0d",
|
||||
"5929534c",
|
||||
"cb2fa491",
|
||||
"e5dad405",
|
||||
"65a17d53",
|
||||
"616bb2fc",
|
||||
"e0205b0d",
|
||||
"c5717a31",
|
||||
"ab103065",
|
||||
"db395b9f",
|
||||
"b603bf16",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"4af8ceb3",
|
||||
"93dde9ce",
|
||||
"6d52d39",
|
||||
"92a7cb38",
|
||||
"26bda54a",
|
||||
"d8853cd0",
|
||||
"199ed625",
|
||||
"e5aa7268",
|
||||
"3e18b5e3",
|
||||
"b80bc546",
|
||||
"1ac6085d",
|
||||
"8f2a94ed",
|
||||
"74a73464",
|
||||
"e01afabb",
|
||||
"ecfa10b3",
|
||||
"72da2670",
|
||||
"b10c8083",
|
||||
"ec670bf9",
|
||||
"d6e1ee91",
|
||||
"5d6f828d",
|
||||
"8604e4d3",
|
||||
"1ca7e0e3",
|
||||
"7429a373",
|
||||
"6dce7bbe",
|
||||
"2c4984cd",
|
||||
"fdffcbfb",
|
||||
"e687b091",
|
||||
"f526345f",
|
||||
"4b1c3867",
|
||||
"8c1996de",
|
||||
"b68dae55",
|
||||
"a364b363",
|
||||
"560d5e17",
|
||||
"6b33fd4a",
|
||||
"4d01a1db",
|
||||
"5ac7cf38",
|
||||
"68d7af89",
|
||||
"fdc9ac97",
|
||||
"90d9dc51",
|
||||
"7b7a0648",
|
||||
"fb3dcb70",
|
||||
"424db01a",
|
||||
"b1540919",
|
||||
"800e8cd6",
|
||||
"f101cb2",
|
||||
"86e6ecb2",
|
||||
"fbecad5a",
|
||||
"b797b6ab",
|
||||
"8b23a5fc",
|
||||
"e660f78e",
|
||||
"2a7816d0",
|
||||
"dc890ef8",
|
||||
"78f70dfc",
|
||||
"c583b68f",
|
||||
"3e62ea2",
|
||||
"1d204fc",
|
||||
"bce9b0b6",
|
||||
"162542cf",
|
||||
"89456dbc",
|
||||
"43dc1b8d",
|
||||
"c47ce183",
|
||||
"6735000b",
|
||||
"893fdf50",
|
||||
"314a19b",
|
||||
"ffb2d0cc",
|
||||
"236b54d5",
|
||||
"c93228e0",
|
||||
"b0774285",
|
||||
"d5dc6c2e",
|
||||
"13983dc5",
|
||||
"b23d1ef1",
|
||||
"75d380b4",
|
||||
"7f9acfac",
|
||||
"a497a4b0",
|
||||
"6c35b5b2",
|
||||
"549329e1",
|
||||
"2f37a9b2",
|
||||
"d7f914f",
|
||||
"ed9bad0b",
|
||||
"8207dd95",
|
||||
"5e29ac40",
|
||||
"d33a4f22",
|
||||
"afd6aade",
|
||||
"7aeabff3",
|
||||
"a210eb36",
|
||||
"231835d0",
|
||||
"2ecb6f39",
|
||||
"69d2bf57",
|
||||
"d73d1671",
|
||||
"827cbbc6",
|
||||
"b388aff6",
|
||||
"50c49665",
|
||||
"77ab5113",
|
||||
"7791c47c",
|
||||
"4842a356",
|
||||
"bfb6a370",
|
||||
"916e5880",
|
||||
"d99c9f6e",
|
||||
"d1fffb27",
|
||||
"8f208955",
|
||||
"fd3c3739",
|
||||
"54ecbcd",
|
||||
"8fc32b40",
|
||||
"29d6c1e2",
|
||||
"b97ebba7",
|
||||
"21937cca",
|
||||
"2a643a6",
|
||||
"7e04b2e2",
|
||||
"3de8b559",
|
||||
"b630dc8f",
|
||||
"b1d166ad",
|
||||
"e84744af",
|
||||
"47cf33ba",
|
||||
"6c5ac796",
|
||||
"e2079509",
|
||||
"33664082",
|
||||
"9846f084",
|
||||
"5ead2d23",
|
||||
"65fb0b48",
|
||||
"c5767d80",
|
||||
"48832f93",
|
||||
"61cca83",
|
||||
"83086f95",
|
||||
"647eba1c",
|
||||
"36f5aef8",
|
||||
"d4607e44",
|
||||
"55f033c7",
|
||||
"595d82cf",
|
||||
"3c96d44",
|
||||
"5d471e85",
|
||||
"d9ed1bf5",
|
||||
"64175b25",
|
||||
"7f546314",
|
||||
"3b0e4326",
|
||||
"b7a93e12",
|
||||
"6fbce2a",
|
||||
"46147e8d",
|
||||
"ab09856b",
|
||||
"42df166c",
|
||||
"7a82cf46",
|
||||
"904215ef",
|
||||
"e5556fa5",
|
||||
"37e3aded",
|
||||
"786bbe16",
|
||||
"eeb2e39c",
|
||||
"be35f824",
|
||||
"10615db7",
|
||||
"ab34bd00",
|
||||
"b4fca2b1",
|
||||
"f4e52727",
|
||||
"d5ad3882",
|
||||
"a9a89d52",
|
||||
"f6fd3535",
|
||||
"7b47db03",
|
||||
"df29b84a",
|
||||
"d89a3a55",
|
||||
"e7bb7378",
|
||||
"5ebbb865",
|
||||
"d6702214",
|
||||
"6afeeca9",
|
||||
"f913c4e3",
|
||||
"6e54bd5d",
|
||||
"cb9b24f0",
|
||||
"90f4c629",
|
||||
"a2f31449",
|
||||
"62fd75b6",
|
||||
"5a4c7ed5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,724 @@
|
|||
## 需求探索
|
||||
|
||||
### 应该支持哪些核心功能?
|
||||
|
||||
* Feed 项目(图钉)的 Masonry 布局。
|
||||
* 随着用户向下滚动,应加载更多项目。
|
||||
|
||||
### 图钉应该如何排序?
|
||||
|
||||
应尽可能地放置图钉,以尊重它们在 feed 中的位置,即,位于返回结果前面的图钉应显示在页面上较高的位置。
|
||||
|
||||
### 支持什么类型的图钉/项目?
|
||||
|
||||
仅关注图像。 排除视频和 GIF。
|
||||
|
||||
### 应用程序将在哪些设备上使用?
|
||||
|
||||
主要用于桌面,但也应在移动设备和平板电脑上使用。
|
||||
|
||||
***
|
||||
|
||||
## 术语表
|
||||
|
||||
我们将“feed 项目”称为“图钉”,并在下面互换使用。 图钉包含基本元数据,例如图像、副标题等。
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
### 服务器端渲染 (SSR) 还是客户端渲染 (CSR)?
|
||||
|
||||
对于像 Pinterest 这样的以视觉内容和用户交互而闻名的网站,混合方法可能是一个不错的选择。 Pinterest 具有复杂的用户界面,具有无尽的滚动和动态内容。 最初,您可以使用 SSR 来处理着陆页和关键内容(用于 SEO 和快速初始加载),然后切换到 CSR 以实现交互性,因为用户浏览图钉和画板。
|
||||
|
||||
实际上,Pinterest 使用一种涉及 SSR 和水合的混合渲染策略。 初始图钉标记和数据包含在初始 HTML 中,但没有任何定位数据。 这将在下面更详细地介绍。
|
||||
|
||||
### 单页应用程序 (SPA) 还是多页应用程序 (MPA)?
|
||||
|
||||
在 feed 应用程序中,将图钉的全部内容在一个模态框中打开,覆盖 feed 是一个常见的 UX。 当用户关闭模态框时,用户可以从他们离开的位置继续滚动 feed。 这种 UX 存在于 Facebook 和 Instagram 以及 Pinterest 上。
|
||||
|
||||
因此,使用 SPA 至关重要,至少对于 feed 和图钉详细信息路由来说是这样,以便浏览单个图钉可以使用客户端导航到图钉详细信息路由。 在 MPA 中,全页导航将销毁当前页面的 DOM 和存储在内存中的 feed 数据,如果用户单击“后退”按钮,则会导致上一页的滚动位置丢失。
|
||||
|
||||
### 组件职责
|
||||
|
||||

|
||||
|
||||
Pinterest feed 的架构图相对简单,因为我们只关注 feed 和布局。
|
||||
|
||||
* **服务器**:提供 HTTP API 来获取 pin 的 feed,以及当用户滚动 feed 时 pin 的后续页面。
|
||||
* **客户端存储**:存储整个应用程序所需的数据。对于这个问题,这里有一个要存储的 pin 列表。
|
||||
* **主页**:显示 pin 列表。
|
||||
* **Masonry 组件**:UI 组件,它接受一个 pin 列表,并以 masonry 布局显示它们。
|
||||
|
||||
**注意**:按照惯例,我们有单独的“控制器”和“客户端存储”实体,但由于 feed 涉及的数据类型和所需的数据获取相当有限,我们可以将控制器合并到客户端存储中,并让客户端存储承担数据获取的责任。然而,在实践中,有更多要处理的状态值和交互,因此分离会更有益。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
主页 feed 显示从服务器获取的 pin 列表,因此此应用程序中涉及的大部分数据将是服务器生成的数据。这些 pin 数据将通过 masonry 布局使用定位数据进行扩充。
|
||||
|
||||
| 实体 | 来源 | 属于 | 字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Feed` | 服务器 | 主页 | `pins` ( `Pin` 列表),`pagination`(分页元数据) |
|
||||
| `Pin` | 服务器 | 主页 | `id`、`created_time`、`image_url`、`alt_text`、...等等,请参见下文 |
|
||||
|
||||
虽然 `Feed` 和 `Pin` 实体属于主页,但所有服务器生成的数据都可以存储在客户端存储中,并由需要它们的组件查询。例如,对于 pin 详细信息页面,假设不需要其他数据,它可以从客户端存储中读取 pin 详细信息,并显示其他详细信息,例如作者、标题、描述。
|
||||
|
||||
客户端存储的形状在这里并不特别重要,只要它采用可以从组件轻松检索的格式即可。像 [新闻 feed 系统设计](/questions/system-design/news-feed-facebook#advanced-normalized-store) 中提到的规范化存储将允许通过 pin ID 进行有效查找。从第二页获取的新 pin 应该与之前的 pin 组合成一个列表,并更新分页参数 (`cursor`)。
|
||||
|
||||
### Pinterest 特有的数据
|
||||
|
||||
由于 Pinterest 的 masonry 布局要求,`Pin` 包含额外的元数据,这些元数据可以实现布局和改进的用户体验,例如:
|
||||
|
||||
* **图像尺寸(高度和宽度)**:这样我们就可以使用数据来计算布局,而无需先加载图像。
|
||||
* **排序**:放置 pin 的位置。详细信息在下面关于“实现 masonry 布局”的部分中介绍。[更多详细信息见下文。](#ordering-items-within-columns)
|
||||
* **响应式图像尺寸**:这是一系列图像 URL 及其相应的大小,以促进响应式和高性能的 masonry 布局。[更多详细信息见下文。](#responsive-images)
|
||||
* **Pin 状态**:pin 的图像是否已加载、绘制/显示或出错。[更多详细信息见下文。](#advanced-paint-scheduling)
|
||||
* **主色(十六进制值)**:在加载图像时用作占位符的背景的主色。[更多详细信息见下文。](#loading-states)
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
对于这个问题,我们只需要一个用于获取 feed 数据的 HTTP API:
|
||||
|
||||
### Feed API
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ----------------------- |
|
||||
| HTTP 方法 | `GET` |
|
||||
| 路径 | `/feed` |
|
||||
| 描述 | 获取 pin 列表。 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `size` | number | 每页的结果数。 |
|
||||
| `cursor` | string | 最后一个获取的项目的标识符。服务器将从此项目继续。 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"size": 20,
|
||||
"next_cursor": "=dXNlcjpVMEc5V0ZYTlo"
|
||||
},
|
||||
"pins": [
|
||||
{
|
||||
"id": 123, // Pin ID.
|
||||
"created_at": "Sun, 01 Oct 2023 17:59:58 +0000",
|
||||
"alt_text": "Pixel art turnip",
|
||||
"dominant_color": "#ffd4ec",
|
||||
"image_url": "https://www.greatcdn.com/img/941b3f3d917f598577b4399b636a5c26.jpg"
|
||||
// In practice, the images payload is more complex, see below.
|
||||
// More metadata is also included.
|
||||
}
|
||||
// ... More pins.
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
实际上,Pinterest 的 feed API 不包含 `size` 参数,并且总是返回 25 个 pin。如果您有兴趣自己查看完整的 feed 负载:
|
||||
|
||||
1. 在您的浏览器中访问 https://pinterest.com。
|
||||
2. 打开网络选项卡。
|
||||
3. 向下滚动以获取下一页的 feed 数据。
|
||||
4. 筛选以获取以“https://www.pinterest.com/resource/UserHomefeedResource/get”开头的请求 URL。
|
||||
|
||||
或者,登录 Pinterest 并访问“https://www.pinterest.com/resource/UserHomefeedResource/get”。
|
||||
|
||||
#### 分页方法
|
||||
|
||||
对于无限滚动 feed,无需跳转到特定页面,因此此处可以使用基于游标的分页,原因类似于 [新闻 feed 系统设计](/questions/system-design/news-feed-facebook#cursor-based-pagination)。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
对于传统的 feed,显示 feed 图像的合理流程如下:
|
||||
|
||||
1. **加载数据**:从服务器获取包含 feed 项目列表的 feed 数据。
|
||||
2. **渲染**:通过将 `<img>` 标签添加到 DOM 来显示图像。
|
||||
3. **图像加载 + 绘制**:浏览器从 CDN 下载图像并将其绘制到屏幕上。
|
||||
|
||||
但是对于 Pinterest,由于以下原因,流程略有不同:
|
||||
|
||||
* **多列布局**:多列布局使事情比单列新闻 feed 更加复杂。使用 flex 和 grid 的传统 CSS 布局不太适合生成 masonry 布局。
|
||||
* **屏幕上存在多个图像**:屏幕上同时存在许多图像。图像以乱序绘制将是一个糟糕的体验。
|
||||
|
||||
考虑到这些因素,要深入研究的 Pinterest 主页 feed 的最重要方面是:
|
||||
|
||||
* 资源加载(feed 和媒体)。
|
||||
* Masonry 布局实现。
|
||||
* 性能。
|
||||
* 还将讨论一种高级技术,即绘制调度。
|
||||
|
||||
有两种类型的资源需要加载:feed 项目(pin)数据和每个 pin 的图像。为了快速的初始加载和绘制,Pinterest 执行以下优化:
|
||||
|
||||
1. 主页是 SSR-ed,这意味着服务器返回的初始 HTML 已经包含接近最终的标记,并包含 pin 的 `<img>` 标签。因此,客户端不需要对 feed API 发出客户端请求并通过网络获取 pin 数据。pin 数据可以被序列化为 JSON 并注入到客户端存储中。
|
||||
2. 使用 `<link rel="preload">` 预加载图像。
|
||||
|
||||
### Feed 加载
|
||||
|
||||
除了初始页面之外,当用户滚动到底部已加载的项目时,必须加载下一页 feed 项目,然后显示。这种体验称为无限滚动,有两种方法可以触发加载:
|
||||
|
||||
1. 当用户到达列表底部时加载下一页。这很糟糕,因为用户必须等待下一页的项目加载,然后等待下一页的图像加载,然后图像才可见。
|
||||
2. 当用户接近列表底部时加载下一页,在用户到达列表底部之前。通过这样做,下一页及其图像可能已经被加载,并且可以立即显示。用户不必等待图像加载,也不会看到任何加载状态。
|
||||
|
||||
#### 动态页面大小
|
||||
|
||||
获取 feed 项目时,Pinterest 使用 24 的页面大小,与设备无关,但可以改进。对于大型显示器,feed API 会经常被请求,因为用户很快就会到达当前 feed 的末尾。为了防止这种情况,客户端可以根据设备尺寸为 `size` 指定不同的值:
|
||||
|
||||
| 设备 | 列数 | 可能的 `size` 值 |
|
||||
| ------------------ | ----------- | --------------------- |
|
||||
| 大/高显示屏 | 6 及以上 | 40+ |
|
||||
| 笔记本电脑 | 4 到 5 | 20 - 40 |
|
||||
| 平板电脑 | 3 | 10 - 20 |
|
||||
| 手机 | 2 | 10 - 20 |
|
||||
|
||||
一个合理的启发式方法是每次获取大约两到三屏的图钉。
|
||||
|
||||
#### 无限滚动
|
||||
|
||||
实现无限滚动的常用方法已在[新闻提要系统设计](/questions/system-design/news-feed-facebook#infinite-scrolling)中介绍。
|
||||
|
||||
### 媒体加载
|
||||
|
||||
#### `<img>` 标签属性
|
||||
|
||||
Pinterest 使用带有以下属性的 `<img>` 标签:
|
||||
|
||||
* `alt`:图像的文本描述,用于图像加载失败和屏幕阅读器。
|
||||
* `fetchpriority`:提供获取图像时要使用的相对优先级的提示。它还不是一个标准。Pinterest 使用 `fetchpriority="auto"`
|
||||
* `loading`:指示浏览器应如何加载图像。Pinterest 使用 `loading="auto"`,这是默认值,不确定为什么需要它。可能的值包括 `"lazy"`,它将加载推迟到图像或 iframe 达到与视口的距离阈值。
|
||||
* `src`:图像 URL。
|
||||
* `srcset`:启用多个图像源,每个源具有不同的分辨率或大小。
|
||||
|
||||
#### 响应式图片
|
||||
|
||||
如上所述,`srcset` 属性允许浏览器根据用户设备的屏幕大小和分辨率选择要显示的图像源。它是响应式 Web 设计技术的一部分,有助于优化不同屏幕大小的图像加载,从而增强用户体验并节省带宽。
|
||||
|
||||
实际上,Pinterest 的 feed API 以以下格式返回图像,而不仅仅是单个 `image_url` 字符串:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "809944314208458040",
|
||||
"alt_text": "Year End Sale Font Bundle",
|
||||
"images": {
|
||||
"170x": {
|
||||
"width": 170,
|
||||
"height": 113,
|
||||
"url": "https://i.pinimg.com/170x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg"
|
||||
},
|
||||
"236x": {
|
||||
"width": 236,
|
||||
"height": 157,
|
||||
"url": "https://i.pinimg.com/236x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg"
|
||||
},
|
||||
"474x": {
|
||||
"width": 474,
|
||||
"height": 316,
|
||||
"url": "https://i.pinimg.com/474x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg"
|
||||
},
|
||||
"736x": {
|
||||
"width": 736,
|
||||
"height": 491,
|
||||
"url": "https://i.pinimg.com/736x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg"
|
||||
},
|
||||
"orig": {
|
||||
"width": 1160,
|
||||
"height": 774,
|
||||
"url": "https://i.pinimg.com/originals/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg"
|
||||
}
|
||||
}
|
||||
// Other data fields omitted.
|
||||
}
|
||||
```
|
||||
|
||||
结果是这样的 `<img>` 元素:
|
||||
|
||||
```html
|
||||
<img
|
||||
alt="This contains an image of: Year End Sale Font Bundle"
|
||||
fetchpriority="auto"
|
||||
loading="auto"
|
||||
src="https://i.pinimg.com/236x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg"
|
||||
srcset="
|
||||
https://i.pinimg.com/236x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg 1x,
|
||||
https://i.pinimg.com/474x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg 2x,
|
||||
https://i.pinimg.com/736x/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg 3x,
|
||||
https://i.pinimg.com/originals/b5/5c/de/b55cde4a2ec2f7827b2deac1312cbd93.jpg 4x
|
||||
" />
|
||||
```
|
||||
|
||||
实现响应式图像的另一种方法是使用 `<picture>` 标签。 `<img srcset="...">` 或 `<picture>` 之间的区别很微妙,在大多数情况下,您可以使用其中任何一个。文章 ["Picture Tags vs Img Tags. Their Uses and Misuses"](https://medium.com/@truszko1/picture-tags-vs-img-tags-their-uses-and-misuses-4b4a7881a8e1) 很好地涵盖了这些区别。
|
||||
|
||||
#### 图片预加载
|
||||
|
||||
初始加载中存在的图像通过 `<link rel="preload">` 预加载。这告诉浏览器有关应尽快加载的关键资源,然后再在 HTML 中发现它们。这对于不易发现的资源特别有用,例如样式表中包含的字体、背景图像或从脚本加载的资源。[在 web.dev 上阅读更多相关信息](https://web.dev/articles/preload-responsive-images)。
|
||||
|
||||
```html
|
||||
<!-- Sample extracted from Pinterest's HTML -->
|
||||
<link
|
||||
rel="preload"
|
||||
nonce="7deba0d15af118e95df9c836e67724dc"
|
||||
href="https://i.pinimg.com/236x/83/84/3a/83843ab4e2cbdea8b99ead3e1f0654d1.jpg"
|
||||
imagesrcset="https://i.pinimg.com/236x/83/84/3a/83843ab4e2cbdea8b99ead3e1f0654d1.jpg 1x, https://i.pinimg.com/474x/83/84/3a/83843ab4e2cbdea8b99ead3e1f0654d1.jpg 2x, https://i.pinimg.com/736x/83/84/3a/83843ab4e2cbdea8b99ead3e1f0654d1.jpg 3x, https://i.pinimg.com/originals/83/84/3a/83843ab4e2cbdea8b99ead3e1f0654d1.jpg 4x"
|
||||
as="image" />
|
||||
<link
|
||||
rel="preload"
|
||||
nonce="7deba0d15af118e95df9c836e67724dc"
|
||||
href="https://i.pinimg.com/236x/02/f8/ac/02f8acb5e46eaa42a909f9be862f519b.jpg"
|
||||
imagesrcset="https://i.pinimg.com/236x/02/f8/ac/02f8acb5e46eaa42a909f9be862f519b.jpg 1x, https://i.pinimg.com/474x/02/f8/ac/02f8acb5e46eaa42a909f9be862f519b.jpg 2x, https://i.pinimg.com/736x/02/f8/ac/02f8acb5e46eaa42a909f9be862f519b.jpg 3x, https://i.pinimg.com/originals/02/f8/ac/02f8acb5e46eaa42a909f9be862f519b.png 4x"
|
||||
as="image" />
|
||||
<!-- Preload 10 images in total -->
|
||||
```
|
||||
|
||||
#### 渐进式 JPEG
|
||||
|
||||
Pin 图像以 [渐进式 JPEG](https://www.hostinger.com/tutorials/website/improving-website-performance-using-progressive-jpeg-images) 的形式提供,每次扫描都会提高图像质量。传统的 JPEG 格式(称为基线 JPEG)按顺序加载图像,从上到下逐行渲染它们,每行都是像素完美的。因此,可能需要一些时间才能完全加载整个图像。
|
||||
|
||||
另一方面,使用渐进式 JPEG,整个图像最初显示为单个实体,尽管处于模糊和像素化的状态。 随着时间的推移,它会逐渐变得清晰和精细,直到出现清晰、完全加载的图像。
|
||||
|
||||
#### 媒体格式
|
||||
|
||||
可以使用多种图像格式,每种格式都有其自身的优缺点。 如今的一般建议是,如果浏览器兼容性不是最高优先级,请使用[Google 的 WebP 格式](https://developers.google.com/speed/webp)。
|
||||
|
||||
Pinterest 可能会使用 JPEG 格式的图像,这可能是因为 JPEG 具有更广泛的浏览器支持(IE11 和旧版 Safari 不支持 WebP),并且渐进式 JPEG 已经提供了出色的加载体验。 Pinterest 的受众是普通消费者,因此浏览器支持至关重要。
|
||||
|
||||
### 布局和渲染
|
||||
|
||||
在介绍了 feed 和媒体加载之后,我们可以讨论如何以砖石布局呈现这些数据,并以提供最佳用户体验的顺序呈现。
|
||||
|
||||
#### 砖石布局实现
|
||||
|
||||
砖石布局以难以实现而闻名。 实现砖石的标志性“砖墙”或交错布局需要精确地定位项目。 这可能很复杂,尤其是在我们希望项目整齐地组合在一起并优化利用可用空间时。 使用 flex 和 grid 的传统 CSS 布局不太适合生成砖石布局。
|
||||
|
||||
有两种流行的方法可以在网络上实现砖石布局:
|
||||
|
||||
1. 列的行
|
||||
2. 绝对定位
|
||||
|
||||
**列的行**:此方法涉及渲染等宽列,然后将项目放置在每列中。 这种方法大量利用浏览器进行定位。
|
||||
|
||||
这种方法的优点是它易于实现,因为它利用了 `display: flex`,并且所需的 CSS 不是很复杂。 如果任何项目的高度发生变化,该列中项目的位置将自动更新。
|
||||
|
||||
但是,一个巨大的缺点是 DOM 顺序现在是先列。 想象一下,键盘用户想要进入最右侧列中的顶部图钉。 使用这样的 DOM:
|
||||
|
||||
```html
|
||||
<div class="container">
|
||||
<div class="column">
|
||||
<!-- Focus is currently here -->
|
||||
<div class="item">1</div>
|
||||
<div class="item">2</div>
|
||||
<div class="item">3</div>
|
||||
<div class="item">4</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="item">5</div>
|
||||
<div class="item">6</div>
|
||||
<div class="item">7</div>
|
||||
<div class="item">8</div>
|
||||
<div class="item">9</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<!-- Desired pin requires pressing the "Tab" key 9 times -->
|
||||
<div class="item">10</div>
|
||||
<div class="item">11</div>
|
||||
<div class="item">12</div>
|
||||
<div class="item">13</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
键盘用户必须先通过两列图钉,然后才能最终到达该项目。 当所需的图钉就在顶部时,这既违反直觉又令人沮丧。 这种布局方法导致键盘用户的 DOM 顺序效率低下,并且是一个决定性因素。
|
||||
|
||||
以下示例使用“列的行”布局实现,项目中的数字表示元素的制表顺序。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/f7s8vt?fontsize=14&hidenavigation=1&theme=dark&module=/src/index.html,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Pinterest layout row of columns"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
***
|
||||
|
||||
**绝对定位**:更好的方法是精确计算图钉在 `position: relative` 父容器中的位置,并通过 `position: absolute; top: Y; left: X` 将它们放置在应该在的位置。
|
||||
|
||||
下图演示了 3 列布局容器的项目的 `top` 和 `left` CSS 样式值。
|
||||
|
||||
```html
|
||||
<div class="container">
|
||||
<div class="item" style="height: 250px; top: 0px; left: 0px;">1</div>
|
||||
<div class="item" style="height: 300px; top: 0px; left: 80px;">2</div>
|
||||
<div class="item" style="height: 110px; top: 0px; left: 160px;">3</div>
|
||||
<div class="item" style="height: 200px; top: 260px; left: 0px;">4</div>
|
||||
<div class="item" style="height: 70px; top: 310px; left: 80px;">5</div>
|
||||
<div class="item" style="height: 330px; top: 120px; left: 160px;">6</div>
|
||||
</div>
|
||||
```
|
||||
|
||||

|
||||
|
||||
使用绝对定位,项目的 DOM 顺序完全不影响视觉结果。因此,我们可以自由地按照制表符顺序排列 DOM。
|
||||
|
||||
以下示例使用绝对定位方法实现,项目中的数字表示元素的制表符顺序。请注意,容器内只有一层子节点;没有嵌套,标记非常干净。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/dqtrlk?fontsize=14&hidenavigation=1&theme=dark&module=/src/index.html,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Pinterest layout position absolute"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
通常,`absolute` 定位的元素会从正常的文档流中移除,并且通常不会影响页面上其他元素的位置或布局。因此,对这些元素所做的更改(例如它们的大小或位置)通常不会触发浏览器重排。但是,即使将更多图钉添加到容器底部(即使它们是 `absolute` 定位的),也会增加容器的高度并导致重排。
|
||||
|
||||
`absolute` 定位的另一个优点是列表虚拟化更容易实现,[更多详细信息见下文](#list-virtualization)。
|
||||
|
||||
这种方法的缺点是客户端必须编写代码来计算图钉应该放置的位置,而不是“列的行”方法,浏览器会为我们完成。除了最初的计算之外,如果窗口大小调整超过断点,以至于列数不同,则需要再次为整个 feed 执行计算。
|
||||
|
||||
此外,如果任何项目的高度发生变化,则其下方的项目将不会重新定位,除非手动对受影响的项目执行计算,并且它们的位置也会更新。
|
||||
|
||||
默认情况下,容器不是流动的,并且具有几个预定义的断点的固定宽度。即使浏览器略微调整大小,只要列数保持不变,计算出的位置仍然可以使用。
|
||||
|
||||
在实践中,Pinterest 使用 CSS 转换(例如 `transform: translateY(100px) translateX(100px)`)而不是使用 `top: 100px` 和 `left: 100px`,大概是因为使用 CSS 转换的性能更高。CSS 转换(例如 `translate`、`scale` 和 `rotate`)通常由现代 Web 浏览器进行硬件加速。这意味着浏览器将转换后元素的渲染卸载到 GPU(图形处理单元),GPU 可以更有效地处理这些操作。另一方面,绝对定位并不总是能从 GPU 加速中获得相同的收益。
|
||||
|
||||
但总的思路是一样的——项目仍然相对于图钉容器的左上角定位。
|
||||
|
||||
Pinterest 的 [开源 Masonry React 组件](https://gestalt.pinterest.systems/web/masonry) [在将项目放置在网格上之前,先将项目渲染到屏幕外以测量项目的高度](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry/README.md#getpositions)。这是因为该组件是一个通用组件,专为首页 feed 之外的用例而构建,其中项目可以包含内容(例如照片标题),而项目的高度事先未知。对于 Pinterest 首页 feed,其中整个项目都是图像,我们已经知道图像的固有比例。使用固定的列宽,我们可以计算出图像/项目所需的高度。因此,无需渲染到屏幕外进行测量。
|
||||
|
||||
即使这种布局方法的实现更复杂,但它更灵活,并且允许更好的可访问性,因为我们可以控制元素的制表符顺序。
|
||||
|
||||
#### 在列中排序项目
|
||||
|
||||
既然我们已经确定应该对项目布局使用绝对定位,那么我们需要决定的下一件事是如何对项目进行排序。请记住,较早出现在 feed 中的图钉应放置在较晚出现的图钉之上。
|
||||
|
||||
有两种常见的方式来对图钉进行排序:
|
||||
|
||||
2. 高度平衡
|
||||
|
||||
**循环放置**:在循环放置中,我们将图钉按顺序放入每个列中,在最后一个列之后绕回到第一列,直到所有图钉都已放置。在三列布局中,图钉放置在这些相应的列中:
|
||||
|
||||
| 列 | 图钉位置(在 feed 中) |
|
||||
| ------ | --------------------------- |
|
||||
| 1 | 1st, 4th, 7th, ... |
|
||||
| 2 | 2nd, 5th, 8th, ... |
|
||||
| 3 | 3rd, 6th, 9th, ... |
|
||||
|
||||
下面的示例显示了一些图钉,这些图钉以循环方式在 3 列布局中使用绝对定位进行排序,以及实现它的循环算法。
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/gnszl2?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Pinterest order sequential"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
这种方法的优点是算法以 O(N) 运行,并且易于实现(只需对列大小取模)。
|
||||
|
||||
但是,这种方法根本没有考虑项目的身高。因此,某些列可能最终会比其他列高。以上面的例子为例,第一列包含一些高个项目,最终比第二列高得多。结果可能在视觉上不令人满意。
|
||||
|
||||
**高度平衡**:在高度平衡放置中,图钉在排列时放置在最短的列中。
|
||||
|
||||
对于三列布局,我们可以使用大小为 3 的数组 (`columnHeights`),其中索引处的值表示该列中项目的总高度。对于每个图钉,通过循环遍历 `columnHeights` 数组一次,我们可以确定最短的列。接下来,我们根据当前高度最短的列计算图钉的位置,并更新该列的高度以用于新添加的图钉。
|
||||
|
||||
```js
|
||||
const pins = [
|
||||
{ height: 160, id: 1 },
|
||||
{ height: 70, id: 2 },
|
||||
{ height: 130, id: 3 },
|
||||
{ height: 160, id: 4 },
|
||||
// ...
|
||||
];
|
||||
|
||||
const NUM_COLS = 3;
|
||||
const GAP = 10;
|
||||
const COL_WIDTH = 70;
|
||||
|
||||
function arrangeHeightBalanced(pins) {
|
||||
const columnHeights = Array(NUM_COLS).fill(0);
|
||||
|
||||
// For each pin, augment with position data.
|
||||
return pins.map((pin) => {
|
||||
// Find the shortest column.
|
||||
let shortestCol = 0;
|
||||
for (let i = 1; i < NUM_COLS; i++) {
|
||||
if (columnHeights[i] < columnHeights[shortestCol]) {
|
||||
shortestCol = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the `left` value of the current pin.
|
||||
const left = shortestCol * COL_WIDTH + Math.max(shortestCol, 0) * GAP;
|
||||
// Calculate the `top` value of the current pin.
|
||||
const top = GAP + columnHeights[shortestCol];
|
||||
// Update the column height.
|
||||
columnHeights[shortestCol] = top + pin.height;
|
||||
|
||||
return {
|
||||
...pin,
|
||||
left,
|
||||
top,
|
||||
width: COL_WIDTH,
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/tl4c5w?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.js,/src/styles.css&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Pinterest order balanced"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
|
||||
结果是布局的列通常具有平衡的高度,这在视觉上更令人愉悦。即使该算法现在以 O(N \* columns) 运行,columns 也是一个常数,并且在包含数百个图钉的现实页面规模上,差异可以忽略不计。
|
||||
|
||||
这种 `columnHeights` 计算应该作为状态保留在 Masonry 组件中,以便在加载下一页图钉并需要在页面上排列时,立即知道列的高度,无需再次计算列的高度或排列已经在屏幕上的图钉。
|
||||
|
||||
**服务器计算**:目前,计算是在客户端完成的。对于 Pinterest,虽然正在使用 SSR,但图钉的初始 HTML 不包含定位数据,并且位置是在客户端计算的。这可能是因为服务器不知道客户端的视口尺寸。
|
||||
|
||||
Pinterest 网格有几个断点宽度,用于不同数量的列,因此每个列数的容器总宽度已经预先知道。从技术上讲,可以在服务器上计算所有可能断点的图钉位置,并将它们发送到客户端。客户端只需要为当前的断点选择预先计算的值。
|
||||
|
||||
假设每个图钉的宽度为 **200px**,它们之间有 **20px** 的间隙,则每个列的容器总宽度为:
|
||||
|
||||
| 列 | 容器宽度 |
|
||||
| ------- | --------------- |
|
||||
| 2 | 420px |
|
||||
| 3 | 640px |
|
||||
| 4 | 860px |
|
||||
| 5 | 1080px |
|
||||
| 6 | 1300px |
|
||||
|
||||
对于显示宽度为 1200px 的笔记本电脑,只有 5 列的空间,因此它们可以直接使用预先计算的 5 列布局的图钉位置。对于移动设备(通常低于 600px),只有 2 列的空间,因此它们可以使用预先计算的 2 列布局的图钉位置。
|
||||
|
||||
但是,这可能是一个微优化,并且服务器计算很难重复用于后续的 feed 加载,因为下一页的定位依赖于客户端当前显示的内容。从工程角度来看,将所有定位计算工作卸载到客户端也更简洁。
|
||||
|
||||
#### 响应性和调整大小
|
||||
|
||||
通过使用绝对定位,我们没有充分利用浏览器的布局功能,因此必须重新实现我们自己的位置计算。如上所述,完成的位置计算仅适用于当前的列数,如果可用列数发生变化(例如,由于调整大小),则必须再次完成位置计算。
|
||||
|
||||
我们可以为页面添加一个 `'resize'` 事件的侦听器,并在调整大小事件触发时重新计算窗口宽度的图钉位置。但是,调整大小事件会经常触发,并且如此频繁地重新计算可能会很昂贵,尤其是在页面上有很多图钉的情况下。防抖或限制事件处理程序可以减少计算位置的次数。实际上,Pinterest 使用防抖,并且仅在窗口停止调整大小时才重新计算布局。
|
||||
|
||||
一种可能的优化是在浏览器空闲时计算所有可能列数的位置。
|
||||
|
||||
要考虑的另一个极端情况是,当滚动位置非常靠下时,用户将从 5 列调整为 2 列,假设滚动位置保持不变,用户将看到比调整大小前高得多的图钉,并且用户可能无法从他们离开的地方恢复。
|
||||
|
||||
理想情况下,滚动位置会进行调整,以便用户仍在查看相同的图钉。我们可以记住调整大小之前显示的图钉,然后计算调整大小后图钉的平均 y 位置,并将其设置为滚动位置,这执行起来相当复杂。在 Pinterest 的案例中,当列数发生变化时,用户会一直滚动到顶部。调整大小并不常见,因此可能不值得处理。
|
||||
|
||||
#### Pinterest 的 Masonry 组件
|
||||
|
||||
关于 Pinterest,一件很棒的事情是他们开源了他们的 React 设计系统组件,包括令人垂涎的 [Masonry component](https://gestalt.pinterest.systems/web/masonry)! 甚至还有一个关于它如何工作的 [简短说明](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry/README.md)。
|
||||
|
||||
关于 Masonry 组件的一些有趣的事情:
|
||||
|
||||
* 它接受项目列表,并且不进行任何数据获取。 当用户滚动到给定阈值(基于容器的高度)时,它会触发一个回调以通知父级,以获取下一页的项目。
|
||||
* 支持多种布局策略:
|
||||
* **默认**:产生具有恒定列宽的项目网格。 这是我们上面讨论的布局。 ([source](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry/defaultLayout.js))
|
||||
* **统一行**:产生具有统一高度的行的项目网格。 该行的高度将是该行中最高的项目。 这与普通表格行为相同。 ([source](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry/uniformRowLayout.js))
|
||||
* **全宽**:产生具有灵活列宽的项目网格。 网格将展开或收缩(通过展开或收缩所有列宽)以适应其容器的宽度; 没有断点。 ([source](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry/fullWidthLayout.js))
|
||||
* 可以启用/禁用虚拟化。
|
||||
|
||||
阅读他们的 [组件文档](https://gestalt.pinterest.systems/web/masonry) 以查看完整的 props 列表,并 [在此处试用该组件](https://codesandbox.io/s/rhtyq2?file=/index.js)。
|
||||
|
||||
#### 图像的边界尺寸
|
||||
|
||||
由于列是固定的,并且图像试图保持其原始纵横比,因此可能会出现一些奇怪的结果。
|
||||
|
||||
* **非常高的图像**:非常高的图像可以占据整个列,当其中一列占据整个页面时,页面看起来会很奇怪。
|
||||
* **非常宽的图像**:非常宽的图像将具有非常小的高度,因为它们必须在列宽内保持纵横比。 因此,用户可能只能看到一条细的水平线,并且几乎不可见。 当放置在 100px 宽的列中时,一个 1000px 宽和 20px 高的图像在保持纵横比的同时,高度只有 2px。
|
||||
|
||||
因此,图像应该有最大和最小高度。 对于此类图像,可以使用 `object-fit: cover` 进行定位,以便用户仍然可以一睹图像的某些部分。
|
||||
|
||||
### 高级:绘制调度
|
||||
|
||||
在上面的讨论中,我们从未真正关注过图像何时被绘制到屏幕上。 我们假设当 `<img>` 标签添加到 DOM 时,图像会立即显示出来。
|
||||
|
||||
术语“绘制”通常用于 Web 渲染引擎和 Web 浏览器的上下文中。 它指的是渲染过程的最后一步,浏览器获取计算出的布局和样式信息,并创建构成网页可见内容的实际像素。 这包括确定页面上每个元素的位置、大小和外观,然后将它们渲染为用户屏幕上的像素。
|
||||
|
||||
对于互联网连接速度快的用户,图像会在 `<img>` 标签添加到 DOM 后立即显示。 图像加载速度非常快,几乎立即绘制到屏幕上,在半秒钟内。 但是,对于互联网连接速度慢的用户,图像需要更长的加载时间,并且根据图像大小,持续时间差异很大。 这将导致图像以随机方式绘制,这可能会让人迷失方向。
|
||||
|
||||
可以采用更复杂的工作流程来解决此问题:
|
||||
|
||||
1. **加载数据**:从服务器获取包含图钉列表的 feed 数据。
|
||||
2. **计算布局**:确定放置图钉的位置。
|
||||
3. **图像加载**:浏览器从 CDN 下载图钉的图像。 可以在不将 `<img>` 标签添加到 DOM 的情况下加载图像,方法是在 JavaScript 中执行 `new Image()`,然后在新创建的 `Image` 对象上设置 `src` 字段。
|
||||
4. **绘制调度**:通过将 `<img>` 标签添加到 DOM 来将图像绘制到屏幕上。
|
||||
|
||||
步骤 2 和 3 可以并行完成。 步骤 4 需要先完全完成步骤 2 和步骤 3。 一种简单而基本的方法是等待所有图像加载完毕(步骤 3 完全完成),然后再将它们绘制到屏幕上。
|
||||
|
||||
#### 绘制方法
|
||||
|
||||
总的来说,有以下绘制方法:
|
||||
|
||||
1. **简单的默认值。** 渲染所有 `<img>` 标签,图像将在加载后立即出现。 这是我们在开始时介绍的情况,为互联网连接速度慢的用户提供了糟糕的体验。
|
||||
2. **顺序加载和绘制。** 这意味着加载一个图像,绘制它,然后对下一个图像重复,直到没有剩余的图像。 这会导致瀑布式加载和绘制,这很慢,而且没有多大意义,因为图像加载可以在所有图像上并行完成。 它比上面的方法更糟。
|
||||
3. **并行加载,一次全部绘制。** 这并不理想,因为用户必须等待最慢的图像加载完毕才能看到任何图像。
|
||||
4. **并行加载,按顺序绘制。** 这是理想的方法,只有在它之前的所有图像都已完全加载后,才会显示图像。
|
||||
|
||||
为了实现理想的方法,需要大量的协调,并且实现起来可能很复杂,但值得庆幸的是,很棒的 React 团队为此提供了一个解决方案。[`Suspense`](https://react.dev/reference/react/Suspense#revealing-content-together-at-once) 组件能够协调渲染/绘制,并让我们轻松决定 UI 的哪些部分应该同时“弹出”。还有一个 `SuspenseList` 组件,它允许按顺序显示项目。`SuspenseList` 尚未发布,但你可以从 [React 2019 主题演讲](https://youtu.be/Tl0S7QkxFE4?t=921) 中了解它的工作原理,并[试用这些示例](https://react-suspense-img.netlify.app/)。
|
||||
|
||||
为了减少所需的协调,尚未在屏幕上的图像也可以在后台预加载,以便在用户向下滚动时准备好将其绘制到视图中。
|
||||
|
||||
### 性能
|
||||
|
||||
#### 列表虚拟化
|
||||
|
||||
在 DOM 的上下文中,虚拟化通常是指在 Web 开发中用于提高渲染大型列表或元素集合(例如在长表格、列表或网格中找到的那些)的性能和效率的技术。虚拟化用于仅渲染当前在视口中可见的元素,而不是渲染整个列表。此技术有助于优化网页性能,尤其是在处理大量动态内容时。
|
||||
|
||||
DOM 虚拟化的基本思想是:
|
||||
|
||||
1. 仅渲染网页的可见部分中的元素。
|
||||
2. 随着用户滚动或与内容交互,动态加载或渲染其他元素。
|
||||
3. 重用和回收 DOM 元素以最大限度地减少内存和性能开销。
|
||||
|
||||
使用 `absolute` 定位布局:
|
||||
|
||||
* 容器确切地知道它应该有多高(最高列的高度),并且可以设置其 `height` 样式值。
|
||||
* 旨在删除的屏幕外 DOM 节点可以这样做,而不会影响其他项目的位置。
|
||||
* 容器已设置 `height`,因此删除底部的项目不会导致容器收缩,并且不会发生滚动位置更改。
|
||||
|
||||
在桌面上,Pinterest 允许在任何时候在 DOM 中最多 40 个图钉。在移动设备上,大约是 10-20 个。可以根据网络状况、设备尺寸、设备处理能力等使用动态值。下面的 GIF 显示了 Pinterest 主页上正在运行的列表虚拟化。
|
||||
|
||||
<video controls src="/img/questions/pinterest/pinterest-virtualization-video.mp4" />
|
||||
|
||||
一些观察结果:
|
||||
|
||||
1. 容器的初始高度为 5386px,但 DOM 中只有 7 个图钉(其直接子元素),大约相当于两屏的内容。
|
||||
2. 随着用户向下滚动,更多的图钉被添加到容器元素的底部。
|
||||
3. 随着用户进一步向下滚动,顶部的图钉将从容器元素中删除。DOM 中最多大约有 12 个图钉。
|
||||
4. 当用户向下滚动足够远以加载下一页项目时,容器高度增加到 10228px。
|
||||
5. 向上滚动会导致底部的图钉从 DOM 中删除,页面上方的图钉被重新插入到顶部。
|
||||
|
||||
#### 回流和重绘
|
||||
|
||||
前面已经提到了回流和重绘,但为了重述这些术语:
|
||||
|
||||
* **回流**:浏览器回流,通常称为布局或重新布局,是 Web 浏览器在对网页的文档对象模型 (DOM) 进行某些更改时所经历的一个过程,用于计算网页中元素的几何形状和位置。这些更改可以包括对页面内容、结构或样式的修改,例如添加、删除或更改元素、更改其尺寸或调整其位置。
|
||||
* **重绘**:浏览器重绘,通常称为“绘制”,是 Web 浏览器在用户屏幕上绘制或渲染网页的可见内容的过程。这涉及创建和更新构成网页视觉表示的像素,包括文本、图像、背景和其他图形元素。当网页上元素的显示发生变化时,通常会触发重绘操作,例如更新 CSS 样式、内容更改或需要重绘页面某些部分的交互。如果进行了回流,则重绘发生在回流之后。
|
||||
|
||||
快速地重新渲染会导致回流和重绘,这可能导致浏览器滞后。由于页面上有很多图像,如果图像一次一个地绘制到屏幕上,则会导致大量的回流和重绘操作。
|
||||
|
||||
减少回流和重绘的技术包括:
|
||||
|
||||
* **批处理 DOM 更改**:对 DOM 进行多次更改,只需一次操作,这可以减少触发的回流次数。
|
||||
* **使用 CSS 转换**:使用 CSS 转换应用转换(如平移、旋转或缩放)通常不会触发回流,这使其成为更新元素外观的更有效方法。
|
||||
* **避免强制同步布局**:某些 DOM 和样式属性在访问时会强制浏览器执行回流。在不需要时避免这些属性可以帮助减少回流。
|
||||
* **虚拟滚动和分页**:对于长列表项,使用虚拟滚动或分页来限制一次可见的元素数量,从而减少回流的影响。
|
||||
* **去抖动和节流**:当响应触发回流的事件时,使用去抖动和节流技术来限制这些操作的频率。
|
||||
|
||||
为了减少回流和重绘,我们可以执行以下操作:
|
||||
|
||||
* 按顺序绘制图像,即仅在所有图像都完全加载后才显示图像。
|
||||
* 在短时间内加载的多个图像只会导致一次回流和重绘。此功能也内置于 [React `Suspense`](https://react.dev/reference/react/Suspense) 中。
|
||||
* 当窗口调整大小时,对 masonry 布局重新计算进行去抖动/节流。
|
||||
* 列表虚拟化,以便页面上的元素数量减少,并且在回流期间需要更少的计算。
|
||||
|
||||
其中一些已经在上面提到过。
|
||||
|
||||
#### 当上次获取时间已过一段时间后自动刷新
|
||||
|
||||
如果您将主页选项卡打开一段时间后返回(也许半小时后),该网站将清除所有已加载的条目并重新获取整个 feed。这有助于保持较低的内存使用率,因为已加载的条目可以从客户端存储中清除。这是一个很好的优化,因为用户不太可能对他们已经滚动过的过时图钉感兴趣。
|
||||
|
||||
如果没有进行虚拟化,定期刷新也可以清除 DOM 状态,从而改善 React 协调并降低内存开销。
|
||||
|
||||

|
||||
|
||||
#### 渐进式 Web 应用程序
|
||||
|
||||
Pinterest 已经做出了重大努力,为其用户提供渐进式 Web 应用程序 (PWA) 体验。渐进式 Web 应用程序是一种 Web 应用程序,可在 Web 浏览器中提供类似原生应用程序的体验。PWA 旨在结合 Web 和移动应用程序的最佳体验,提供离线访问、推送通知和快速加载时间等功能。
|
||||
|
||||
他们的 PWA 案例研究和回顾已公开出版:
|
||||
|
||||
* [Pinterest 渐进式 Web 应用程序性能案例研究](https://medium.com/dev-channel/a-pinterest-progressive-web-app-performance-case-study-3bd6ed2e6154)
|
||||
* [一年 PWA 回顾](https://medium.com/pinterest-engineering/a-one-year-pwa-retrospective-f4a2f4129e05)
|
||||
|
||||
### 网络
|
||||
|
||||
由于屏幕上要显示许多图像,因此需要下载许多图像,浏览器会发出多个并发网络请求。传统上,浏览器对每个域的并行 HTTP 连接的最大数量有限制。这导致 Web 服务使用不同的域名来托管其图像。但是,HTTP/2 允许通过单个连接进行多个请求,从而减少了对多个并行连接的需求。
|
||||
|
||||
Pinterest 对其 CDN (`pinimg.com`) 使用单个域并支持 HTTP/3,因此页面不会遇到超出最大并行连接数的问题。
|
||||
|
||||
### 用户体验
|
||||
|
||||
#### 加载状态
|
||||
|
||||
对于网络速度较慢的设备,图像可能需要一段时间才能加载,因此显示图像的占位符可以改善用户体验。Pinterest 没有显示通用灰色框作为占位符,而是确定了每个图像的主色,并渲染了一个以该主色为背景的框。
|
||||
|
||||

|
||||
|
||||
#### 错误处理
|
||||
|
||||
图像有时可能无法加载,并且有几种处理方法:
|
||||
|
||||
* **忽略失败的图像**。这可能是最简单和合理的方法。Pinterest feed 具有一定的随机性,如果某个特定的图钉未能加载,用户不会意识到。
|
||||
* **重试加载并稍后插入**。客户端可以重试加载图像,如果成功,则将其插入当前 feed 的底部或作为下一页呈现的一部分。
|
||||
* **显示错误消息**。可以在为该图钉分配的空间内显示错误消息。但是,可能没有足够的空间来显示消息,尤其是在消息很长(在某些语言中可能)并且可用的图钉空间很小的情况下。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
关于此问题的 i18n 方面,由于重点在于布局,因此没有太多可讨论的。对于 RTL 语言,可以轻松调整 masonry 布局算法,使其从右侧开始,而不是左侧。
|
||||
|
||||
### 可访问性 (a11y)
|
||||
|
||||
#### 屏幕阅读器
|
||||
|
||||
* `<img>` 的 `alt` 属性。
|
||||
* feed 容器的 `role="list"` 属性。
|
||||
* feed 项目的 `role="listitem"` 属性。
|
||||
|
||||
#### 键盘支持
|
||||
|
||||
* 确保标签顺序与浏览顺序匹配,这可以通过绝对定位的 pin 来完成。
|
||||
* 当用户在页面上向下很远时,按下 <kbd>Tab</kbd> 应该将焦点放在视口内的 pin 上,而不是 feed 顶部的 pin 上。
|
||||
|
||||
### 其他交互
|
||||
|
||||
此问题的重点在于 feed masonry 布局,我们有意省略了有关其他常见 feed 交互的详细信息:
|
||||
|
||||
* **查看 pin 的详细信息**。这会在模态框中显示 pin 的详细信息,显示图像和其他详细信息,如标题、描述、作者。关闭模态框会将用户带回 feed,并恢复滚动位置,允许他们从上次中断的地方继续浏览。
|
||||
* **保存 pin**。利用乐观更新。
|
||||
* **Pin 创建**。上传图像并填写基本信息,如标题和描述。
|
||||
* **仅在交互时显示的额外操作**。仅在这些非必要操作被使用或页面空闲时,才延迟加载代码。
|
||||
|
||||
这些主题在 [新闻 feed 系统设计](/questions/system-design/news-feed-facebook) 中有详细介绍。
|
||||
|
||||
## 参考
|
||||
|
||||
* [Pinterest 渐进式 Web 应用程序性能案例研究](https://medium.com/dev-channel/a-pinterest-progressive-web-app-performance-case-study-3bd6ed2e6154)
|
||||
* [一年的 PWA 回顾](https://medium.com/pinterest-engineering/a-one-year-pwa-retrospective-f4a2f4129e05)
|
||||
* [改善 Pinterest 上的 GIF 性能](https://medium.com/pinterest-engineering/improving-gif-performance-on-pinterest-8dad74bf92f1)
|
||||
* Gestalt(Pinterest 的设计系统)
|
||||
* [Masonry 组件](https://gestalt.pinterest.systems/web/masonry)
|
||||
* [Masonry 源代码](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry.js)
|
||||
* [Masonry 的工作原理](https://github.com/pinterest/gestalt/blob/master/packages/gestalt/src/Masonry/README.md)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "e0db7bd8",
|
||||
"excerpt": "f3ecd26b"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"467690db",
|
||||
"63f5ddeb",
|
||||
"960ef338",
|
||||
"d784a5d9"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"467690db",
|
||||
"63f5ddeb",
|
||||
"960ef338",
|
||||
"d784a5d9"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: 投票小部件
|
||||
excerpt: 设计一个可以嵌入网站的投票小部件
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个可以轻松嵌入网站的投票小部件,例如文章和博客,以允许网站查看者对选项进行投票。
|
||||
|
||||

|
||||
|
||||
### 要求
|
||||
|
||||
* 小部件显示以下信息:
|
||||
* 用户可以投票的选项列表。
|
||||
* 每个选项的最新投票数。这仅在用户提交投票后显示。
|
||||
* 提供了以下后端 API:
|
||||
* 获取投票结果。
|
||||
* 记录对现有投票选项的新投票。
|
||||
* 从现有投票选项中删除投票。
|
||||
* 网站所有者将其嵌入其网站中不应花费太多精力。
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"74734483",
|
||||
"2563bbee",
|
||||
"51c697",
|
||||
"c84cd8f2",
|
||||
"2cc96933",
|
||||
"3bba1926",
|
||||
"571c28ad",
|
||||
"92474de3",
|
||||
"a590b99e",
|
||||
"33f07e13",
|
||||
"e5ba18ee",
|
||||
"1b96241d",
|
||||
"8838654d",
|
||||
"b482522a",
|
||||
"19bd51f7",
|
||||
"50e8bd4e",
|
||||
"b196653e",
|
||||
"1776ea1d",
|
||||
"bc3ae319",
|
||||
"3af2f1a7",
|
||||
"6b51fb4a",
|
||||
"b408ec6",
|
||||
"c9f35603",
|
||||
"799fa3a9",
|
||||
"f0663a5f",
|
||||
"8ef2758e",
|
||||
"35d1fc40",
|
||||
"b2a7b073",
|
||||
"5ce1d785",
|
||||
"e45737e4",
|
||||
"944cd86e",
|
||||
"d0074291",
|
||||
"1dbf50a7",
|
||||
"7f03dca9",
|
||||
"56e4e567",
|
||||
"9b455982",
|
||||
"a4376d95",
|
||||
"4d82c3ae",
|
||||
"2e738ec7",
|
||||
"20d523b4",
|
||||
"b7f68240",
|
||||
"65738f7e",
|
||||
"dc52d080",
|
||||
"7b0938a8",
|
||||
"1dbf50a7",
|
||||
"24551199",
|
||||
"56e4e567",
|
||||
"a68bdaba",
|
||||
"262f13cd",
|
||||
"d163405a",
|
||||
"8ce8c625",
|
||||
"1905600d",
|
||||
"edeb9720",
|
||||
"1316cd86",
|
||||
"447d889d",
|
||||
"b0630f7e",
|
||||
"48fb57c0",
|
||||
"17964adc",
|
||||
"2d9d19cf",
|
||||
"bd9f3d9d",
|
||||
"73011d0",
|
||||
"91d66590",
|
||||
"81b61e7c",
|
||||
"700fe12a",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"3282dd62",
|
||||
"d754875e",
|
||||
"be0d38bc",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"c7e4a6b9",
|
||||
"7841606f",
|
||||
"68eb2e5e",
|
||||
"bef3ca94",
|
||||
"36672382",
|
||||
"ef921721",
|
||||
"58bc69bc",
|
||||
"d6f38cbb",
|
||||
"760b796c",
|
||||
"9871a7fc",
|
||||
"fe8325d7",
|
||||
"17dfd1a9",
|
||||
"3615ca3",
|
||||
"57b4966b",
|
||||
"f04a6aee",
|
||||
"80157c9b",
|
||||
"b38b819",
|
||||
"73484797",
|
||||
"d31cc1e8",
|
||||
"fb802507",
|
||||
"63b2e59d",
|
||||
"8783b26b",
|
||||
"a9beb3b7",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"1966f0c0",
|
||||
"51b08a31",
|
||||
"8eac9bbf",
|
||||
"284f6485",
|
||||
"23d12c34",
|
||||
"24f1d64c",
|
||||
"f33e601",
|
||||
"bae87fb3",
|
||||
"30e1eb7b",
|
||||
"d1003043",
|
||||
"5ce5b5a0",
|
||||
"959f5841",
|
||||
"345293b",
|
||||
"ed69e673",
|
||||
"8fc926da",
|
||||
"2ac6bc03",
|
||||
"d50fc381",
|
||||
"ec61f4ee",
|
||||
"69886f8b",
|
||||
"f0d35718",
|
||||
"b89cfab2",
|
||||
"5dfb3b4d",
|
||||
"ab34bd00",
|
||||
"1de3fc54",
|
||||
"9846f084",
|
||||
"ea7a5573",
|
||||
"d61b0b50",
|
||||
"25e48007",
|
||||
"1a0a59bb",
|
||||
"baa0e04b",
|
||||
"e556e13c",
|
||||
"aba95419",
|
||||
"c8feee32",
|
||||
"574af85d",
|
||||
"50119dff",
|
||||
"66605fca",
|
||||
"5ebbb865",
|
||||
"bf9d4988",
|
||||
"39844871",
|
||||
"52976047",
|
||||
"6fb85806",
|
||||
"eeb2e39c",
|
||||
"984c4d96",
|
||||
"df29b84a",
|
||||
"58bdf80d",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"87c7bcbe",
|
||||
"aa12a41"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"74734483",
|
||||
"2563bbee",
|
||||
"51c697",
|
||||
"c84cd8f2",
|
||||
"2cc96933",
|
||||
"3bba1926",
|
||||
"571c28ad",
|
||||
"92474de3",
|
||||
"a590b99e",
|
||||
"33f07e13",
|
||||
"e5ba18ee",
|
||||
"1b96241d",
|
||||
"8838654d",
|
||||
"b482522a",
|
||||
"19bd51f7",
|
||||
"50e8bd4e",
|
||||
"b196653e",
|
||||
"1776ea1d",
|
||||
"bc3ae319",
|
||||
"3af2f1a7",
|
||||
"6b51fb4a",
|
||||
"b408ec6",
|
||||
"c9f35603",
|
||||
"799fa3a9",
|
||||
"f0663a5f",
|
||||
"8ef2758e",
|
||||
"35d1fc40",
|
||||
"b2a7b073",
|
||||
"5ce1d785",
|
||||
"e45737e4",
|
||||
"944cd86e",
|
||||
"d0074291",
|
||||
"1dbf50a7",
|
||||
"7f03dca9",
|
||||
"56e4e567",
|
||||
"9b455982",
|
||||
"a4376d95",
|
||||
"4d82c3ae",
|
||||
"2e738ec7",
|
||||
"20d523b4",
|
||||
"b7f68240",
|
||||
"65738f7e",
|
||||
"dc52d080",
|
||||
"7b0938a8",
|
||||
"1dbf50a7",
|
||||
"24551199",
|
||||
"56e4e567",
|
||||
"a68bdaba",
|
||||
"262f13cd",
|
||||
"d163405a",
|
||||
"8ce8c625",
|
||||
"1905600d",
|
||||
"edeb9720",
|
||||
"1316cd86",
|
||||
"447d889d",
|
||||
"b0630f7e",
|
||||
"48fb57c0",
|
||||
"17964adc",
|
||||
"2d9d19cf",
|
||||
"bd9f3d9d",
|
||||
"73011d0",
|
||||
"91d66590",
|
||||
"81b61e7c",
|
||||
"700fe12a",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"3282dd62",
|
||||
"d754875e",
|
||||
"be0d38bc",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"c7e4a6b9",
|
||||
"7841606f",
|
||||
"68eb2e5e",
|
||||
"bef3ca94",
|
||||
"36672382",
|
||||
"ef921721",
|
||||
"58bc69bc",
|
||||
"d6f38cbb",
|
||||
"760b796c",
|
||||
"9871a7fc",
|
||||
"fe8325d7",
|
||||
"17dfd1a9",
|
||||
"3615ca3",
|
||||
"57b4966b",
|
||||
"f04a6aee",
|
||||
"80157c9b",
|
||||
"b38b819",
|
||||
"73484797",
|
||||
"d31cc1e8",
|
||||
"fb802507",
|
||||
"63b2e59d",
|
||||
"8783b26b",
|
||||
"a9beb3b7",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"1966f0c0",
|
||||
"51b08a31",
|
||||
"8eac9bbf",
|
||||
"284f6485",
|
||||
"23d12c34",
|
||||
"24f1d64c",
|
||||
"f33e601",
|
||||
"bae87fb3",
|
||||
"30e1eb7b",
|
||||
"d1003043",
|
||||
"5ce5b5a0",
|
||||
"959f5841",
|
||||
"345293b",
|
||||
"ed69e673",
|
||||
"8fc926da",
|
||||
"2ac6bc03",
|
||||
"d50fc381",
|
||||
"ec61f4ee",
|
||||
"69886f8b",
|
||||
"f0d35718",
|
||||
"b89cfab2",
|
||||
"5dfb3b4d",
|
||||
"ab34bd00",
|
||||
"1de3fc54",
|
||||
"9846f084",
|
||||
"ea7a5573",
|
||||
"d61b0b50",
|
||||
"25e48007",
|
||||
"1a0a59bb",
|
||||
"baa0e04b",
|
||||
"e556e13c",
|
||||
"aba95419",
|
||||
"c8feee32",
|
||||
"574af85d",
|
||||
"50119dff",
|
||||
"66605fca",
|
||||
"5ebbb865",
|
||||
"bf9d4988",
|
||||
"39844871",
|
||||
"52976047",
|
||||
"6fb85806",
|
||||
"eeb2e39c",
|
||||
"984c4d96",
|
||||
"df29b84a",
|
||||
"58bdf80d",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"87c7bcbe",
|
||||
"aa12a41"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
## 需求探索
|
||||
|
||||
这些是您应该向面试官提出的问题,以便更深入地研究问题并完善需求。
|
||||
|
||||
### 该组件最重要的方面是什么?
|
||||
|
||||
* 易于将轮询小部件嵌入网站。
|
||||
* 选民的用户体验。
|
||||
|
||||
### 小部件是否显示投票选项的人员的详细信息(例如缩略图)?
|
||||
|
||||
我们是否显示该级别的详细信息将影响数据模型和 API。我们假设一个基本版本,我们只需要显示计数。
|
||||
|
||||
### 用户可以对多个选项进行投票吗?
|
||||
|
||||
是的,用户可以对多个选项进行投票。
|
||||
|
||||
### 我们如何确定要呈现的每个选项条的长度/比例?
|
||||
|
||||
您可以自由决定。
|
||||
|
||||
### 选项应以什么顺序显示?按受欢迎程度/用户已投票/随机?
|
||||
|
||||
受欢迎程度。
|
||||
|
||||
### 如何确定选项?用户可以添加更多选项吗?
|
||||
|
||||
轮询由网站所有者在单独的管理门户中创建,选项在轮询创建期间确定,之后无法修改。
|
||||
|
||||
### 小部件中显示的选项数量是否有限制?
|
||||
|
||||
选项的最大数量是 6。
|
||||
|
||||
### 用户是否必须登录页面才能投票?
|
||||
|
||||
任何人都可以投票,无论他们是否登录。 投票应针对同一用户保留。
|
||||
|
||||
## 架构
|
||||
|
||||
### 渲染方法
|
||||
|
||||
我们应该首先评估可能的渲染方法,因为它会影响架构和后续讨论。
|
||||
|
||||
一般来说,在页面上渲染外部小部件/组件有两种方法:
|
||||
|
||||
* 在 `<iframe>` 中渲染(不同的浏览器上下文)
|
||||
* 直接在页面内渲染(相同的浏览器上下文)
|
||||
|
||||
请注意,我们正在讨论如何渲染小部件,这与分发方法(如何运行渲染小部件的代码)不同。
|
||||
|
||||
#### 在 `<iframe>` 中渲染(不同的浏览器上下文)
|
||||
|
||||
`<iframe>`(内联框架)是页面上的一个 HTML 标签,它接受一个 `src` 属性,该属性是您要嵌入到宿主网站中的网站的 URL。
|
||||
|
||||
流行的可嵌入小部件,例如 Facebook 的“赞”按钮、Twitter 的嵌入推文、YouTube 的嵌入视频和 Disqus 的嵌入评论,都是 `iframe`。 它们本质上是仅渲染要嵌入的内容的网站。
|
||||
|
||||
查看这些示例,亲自看看:
|
||||
|
||||
**Facebook 赞按钮**
|
||||
|
||||
<iframe
|
||||
src="https://www.facebook.com/plugins/like.php?href=https%3A%2F%2Fdevelopers.facebook.com%2Fdocs%2Fplugins%2F&layout=standard&action=like&size=small&share=true&height=35&appId=560696510762145"
|
||||
style={{
|
||||
height: 35,
|
||||
width: '100%',
|
||||
maxWidth: 450,
|
||||
}}
|
||||
/>
|
||||
|
||||
**Twitter 嵌入推文**
|
||||
|
||||
<iframe
|
||||
src="https://platform.twitter.com/embed/Tweet.html?dnt=false&embedId=twitter-widget-0&features=eyJ0ZndfdGltZWxpbmVfbGlzdCI6eyJidWNrZXQiOlsibGlua3RyLmVlIiwidHIuZWUiLCJ0ZXJyYS5jb20uYnIiLCJ3d3cubGlua3RyLmVlIiwid3d3LnRyLmVlIiwid3d3LnRlcnJhLmNvbS5iciJdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2hvcml6b25fdGltZWxpbmVfMTIwMzQiOnsiYnVja2V0IjoidHJlYXRtZW50IiwidmVyc2lvbiI6bnVsbH0sInRmd190d2VldF9lZGl0X2JhY2tlbmQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3JlZnNyY19zZXNzaW9uIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19jaGluX3BpbGxzXzE0NzQxIjp7ImJ1Y2tldCI6ImNvbG9yX2ljb25zIiwidmVyc2lvbiI6bnVsbH0sInRmd190d2VldF9yZXN1bHRfbWlncmF0aW9uXzEzOTc5Ijp7ImJ1Y2tldCI6InR3ZWV0X3Jlc3VsdCIsInZlcnNpb24iOm51bGx9LCJ0Zndfc2Vuc2l0aXZlX21lZGlhX2ludGVyc3RpdGlhbF8xMzk2MyI6eyJidWNrZXQiOiJpbnRlcnN0aXRpYWwiLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2V4cGVyaW1lbnRzX2Nvb2tpZV9leHBpcmF0aW9uIjp7ImJ1Y2tldCI6MTIwOTYwMCwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D&frame=false&hideCard=false&hideThread=false&id=1141039841993355264&lang=en&origin=https%3A%2F%2Fhelp.twitter.com%2Fen%2Fusing-twitter%2Fhow-to-embed-a-tweet&sessionId=a508612a6d2d572621ff9073f1c52dca0cc628da&theme=light&widgetsVersion=1c23387b1f70c%3A1664388199485"
|
||||
style={{
|
||||
height: 396,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
}}
|
||||
frameBorder={0}
|
||||
scrolling="no"
|
||||
/>
|
||||
|
||||
使用 `iframe` 是将您的内容嵌入到第三方网站中的一种非常常见的技术。 对于轮询小部件,该小部件将是定义为 `iframe` 的 `src` 的网站上呈现的唯一 UI。
|
||||
|
||||
**优点**
|
||||
|
||||
* `iframe` 是一个单独的网站,因此是一个单独的 [浏览上下文](https://developer.mozilla.org/en-US/docs/Glossary/Browsing_context)。 `iframe` 的内容与托管站点隔离,反之亦然。
|
||||
* 小部件的样式不会受到宿主网站的任何 CSS 的影响。
|
||||
* 小部件的 JavaScript 环境不受在宿主网站上运行的脚本的影响,这些脚本可能包含 polyfill 或 monkeypatching,从而以不可预测的方式影响运行时行为。
|
||||
* 设置简单,因为在页面上添加 `<iframe>` 仅涉及更改 HTML。 用户不需要太多技术知识即可实现这一点。
|
||||
|
||||
**缺点**
|
||||
|
||||
* 需要一个 Web 服务器来托管呈现该小部件的网站。这不是什么大问题,因为无论如何都需要一个 Web 服务器来提供轮询结果。根据您构建的网站类型,此设置的范围可以从简单到复杂。
|
||||
* 加载一个单独的网站比直接将其呈现在页面中要慢。
|
||||
* 由于隔离,宿主网站无法使用 CSS 自定义组件内部。
|
||||
|
||||
有两种常见的方法可以在页面上获取 `<iframe>`,以 [Facebook 的“赞”按钮开发者文档](https://developers.facebook.com/docs/plugins/like-button/) 为例:
|
||||
|
||||
**1. 运行 JavaScript 代码,动态地将 `<iframe>` 添加到 DOM 中。**
|
||||
|
||||

|
||||
|
||||
**2. 将 `<iframe>` 代码直接嵌入到 HTML 中。**
|
||||
|
||||

|
||||
|
||||
JavaScript 方法的好处是,脚本可以根据环境自定义 iframe 渲染(例如主题、大小)。`<iframe>` 方法更简单,但灵活性较差。
|
||||
|
||||
#### 在页面内渲染(相同的浏览器上下文)
|
||||
|
||||
就像脚本可以动态地将 `<iframe>` 注入到 DOM 中一样,它也可以直接添加渲染轮询和轮询结果所需的 DOM 元素。
|
||||
|
||||
**优点**
|
||||
|
||||
* 在同一页面中快速渲染轮询小部件。
|
||||
|
||||
**缺点**
|
||||
|
||||
* 轮询小部件可能会受到宿主网站的 JavaScript 环境和全局样式的 影响。无法确定存在什么样的全局样式,并且小部件的外观很可能会受到网站 CSS 的影响。
|
||||
|
||||
问题是如何在页面上运行第三方 JavaScript 以实现上述目的。
|
||||
|
||||
**1. 通过 CDN 下载脚本。**
|
||||
|
||||
这种方法类似于 Facebook 的“赞”按钮 JavaScript SDK 方法,即通过在页面中添加一个 `<script>` 标签来下载和执行一些外部 JavaScript。
|
||||
|
||||
**2. 通过 `npm` 分发代码**
|
||||
|
||||
`npm` 是 JavaScript 项目的包管理器,小部件代码可以打包成一个 npm 项目,以便项目可以将其添加为依赖项。网站所有者需要具备如何将新的 `npm` 包添加到其 `package.json` 中的技术知识。
|
||||
|
||||
仅通过 `npm` 分发不是一个好主意,因为并非所有网站都是使用基于 JavaScript 的构建系统构建的。像 Wordpress、Webflow 和 Blogger 这样的低代码网站不允许通过 npm 在页面上包含第三方 JavaScript 代码。
|
||||
|
||||
#### 哪种渲染方法更好?
|
||||
|
||||
对于嵌入小部件,由于 `iframe` 提供的样式和环境的封装,`<iframe>` 方法显然更好。
|
||||
|
||||
#### 哪种分发方法更适合渲染 `<iframe>`?
|
||||
|
||||
直接在 HTML 中嵌入 `<iframe>` 是最简单的方法,使用基于 `<script>` 的方法的好处并不多。也就是说,如果能让开发者选择这两种方法就好了。Facebook 和 YouTube 都提供了 JavaScript SDK 和直接 `<iframe>` 嵌入选项。
|
||||
|
||||
下面的讨论将假设使用 `<iframe>` 嵌入方法。
|
||||
|
||||
### 图表
|
||||
|
||||

|
||||
|
||||
### 组件职责
|
||||
|
||||
* **Host Website**: 嵌入该小部件作为 `<iframe>` 的宿主网站。
|
||||
* **App Server**: 通过提供所需的 HTML、CSS、JavaScript 将小部件 UI 呈现为网站。
|
||||
* **API Server**: 返回小部件的投票结果 JSON 数据并接受新投票的服务器。
|
||||
* **Client Store**: 与 API 服务器交互并存储 UI 数据的模块。
|
||||
* **Polls UI**: 投票选项的 UI。
|
||||
|
||||
*注意:为了清晰起见,**App Server** 和 **API Server** 已被拆分为单独的组件,但它们可以是具有相同域的同一服务器。
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
轮询小部件的数据模型非常简单。类似这样的就足够了:
|
||||
|
||||
```js
|
||||
const state = {
|
||||
lastUpdated: 1628634891,
|
||||
totalVotes: 421,
|
||||
question: 'Which is your favorite JavaScript library/framework?',
|
||||
options: [
|
||||
{
|
||||
id: 123,
|
||||
label: 'React',
|
||||
count: 234,
|
||||
userVotedForOption: false,
|
||||
},
|
||||
{
|
||||
id: 124,
|
||||
label: 'Vue',
|
||||
count: 183,
|
||||
userVotedForOption: true,
|
||||
},
|
||||
{
|
||||
id: 125,
|
||||
label: 'Svelte',
|
||||
count: 51,
|
||||
userVotedForOption: true,
|
||||
},
|
||||
// ...
|
||||
],
|
||||
// Which option(s) the user has selected.
|
||||
selectedOptions: [124, 125],
|
||||
};
|
||||
```
|
||||
|
||||
只有 `selectedOptions` 字段是仅客户端状态,其余字段是服务器生成的数据。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
有三种 API 需要讨论:
|
||||
|
||||
* **Embed API**: 网站应如何嵌入 `<iframe>`。
|
||||
* **Components API**: 如何构建轮询小部件以及组件接受的 props。
|
||||
* **Server APIs**: 用于获取结果、记录新投票和删除投票的 HTTP API。
|
||||
|
||||
### Embed API
|
||||
|
||||
应向网站提供一些代码,以便复制和粘贴以呈现轮询小部件。`iframe` 的 `src` 属性应为特定于轮询实例的唯一 URL。
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://greatpollwidget.com/embed/{poll_id}"
|
||||
style="border:none;overflow:hidden"
|
||||
title="Poll widget for your favorite JavaScript framework"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
style={{
|
||||
height: 200,
|
||||
width: '100%',
|
||||
maxWidth: 450,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
默认情况下,`iframe` 使用默认样式呈现,因此 `frameborder="0"`、`scrolling="no"` 等属性和内联样式有助于删除边框和滚动条,使小部件看起来像是页面的一部分,而不是明显地由 `iframe` 呈现。
|
||||
|
||||
### Components API
|
||||
|
||||
* `Poll`
|
||||
* Server URL
|
||||
* `PollOptionList`
|
||||
* 显示的最大选项数
|
||||
* `PollOptionItem`
|
||||
* 标签
|
||||
* 选项的投票数
|
||||
* 事件处理程序:`onClick`
|
||||
|
||||
```jsx
|
||||
// React 中的示例代码
|
||||
<Poll submitUrl="https://greatpollwidget.com/submit/{poll_id}">
|
||||
<PollOptionList>
|
||||
{options.map((option) => (
|
||||
<PollOptionItem
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
count={option.count}
|
||||
isSelected={option.userVotedForOption}
|
||||
onVote={() => {
|
||||
submitVote(option.id);
|
||||
}}
|
||||
onUnvote={() => {
|
||||
removeVote(option.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PollOptionList>
|
||||
</Poll>
|
||||
```
|
||||
|
||||
### 服务器 API
|
||||
|
||||
理想情况下,服务器 API 应在同一域上提供服务,这样就不需要处理 CORS,例如 `https://greatpollwidget.com/api/{poll_id}/results` 和 `https://greatpollwidget.com/api/{poll_id}/submit`。
|
||||
|
||||
#### 获取结果
|
||||
|
||||
API 应该以什么格式返回结果?
|
||||
|
||||
**1. 返回选项和每个选项的计数**:服务器返回类似如下内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"totalVotes": 421,
|
||||
"question": "你最喜欢的 JavaScript 库/框架是什么?",
|
||||
"options": [
|
||||
{
|
||||
"id": 123,
|
||||
"label": "React",
|
||||
"count": 234,
|
||||
"userVotedForOption": false
|
||||
},
|
||||
{
|
||||
"id": 124,
|
||||
"label": "Vue",
|
||||
"count": 183,
|
||||
"userVotedForOption": false
|
||||
},
|
||||
{
|
||||
"id": 125,
|
||||
"label": "Svelte",
|
||||
"count": 51,
|
||||
"userVotedForOption": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* 优点
|
||||
* 客户端不需要对结果进行制表,只需渲染结果即可。
|
||||
* 负载很小,只包含需要显示的确切数据。
|
||||
* 缺点
|
||||
* 服务器必须进行处理,但可能更好,因为服务器可以缓存/记忆结果,尤其是对于热门投票,并为不同的用户返回缓存的结果。
|
||||
* 服务器将需要不时更新制表。 这是一个很好的用例,适用于 Redis/Memcached 等内存键/值存储。
|
||||
|
||||
**2. 原始的投票回复列表**:服务器返回类似如下内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"question": "Which is your favorite JavaScript library/framework?",
|
||||
"options": [
|
||||
{
|
||||
"id": 123,
|
||||
"label": "React"
|
||||
},
|
||||
{
|
||||
"id": 124,
|
||||
"label": "Vue"
|
||||
},
|
||||
{
|
||||
"id": 125,
|
||||
"label": "Svelte"
|
||||
}
|
||||
],
|
||||
"votes": [
|
||||
{
|
||||
"optionId": 123,
|
||||
"createdAt": 1628634891
|
||||
},
|
||||
{
|
||||
"optionId": 123,
|
||||
"createdAt": 1628634892
|
||||
},
|
||||
{
|
||||
"optionId": 124,
|
||||
"createdAt": 1628634893
|
||||
}
|
||||
// ...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* 优点
|
||||
* 可以在客户端完全进行制表,因此排序/过滤没有任何网络延迟。
|
||||
* 缺点
|
||||
* 当有很多回复时,无法很好地扩展,网络负载将非常大。
|
||||
* 客户端需要对投票进行制表,这在低端设备上以及有很多投票时可能会很昂贵。
|
||||
|
||||
**选择哪个?**
|
||||
|
||||
直接返回计数通常是更好的选择,因为通常不需要在客户端对数据进行制表或操作,对于有成千上万张选票的热门投票,这种方法无法扩展。
|
||||
|
||||
#### 提交投票和取消投票
|
||||
|
||||
投票提交/取消投票 API 可以接受选项 ID 列表,并以与投票结果获取 API 类似的格式返回更新后的投票结果。
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
{/* TODO: 讨论渲染方法:SSR 与 CSR */}
|
||||
|
||||
### 跨会话保留投票
|
||||
|
||||
因为任何人都可以投票,而无需先登录,所以我们需要一种方法来跨会话识别用户,否则用户在关闭浏览器标签后将看不到他们已经投票。
|
||||
|
||||
我们可以使用 cookie 通过生成基于字符串的唯一指纹(例如使用 `uuid`)来唯一标识每个用户,以在初始加载期间用作用户 ID cookie(如果不存在现有的用户 ID cookie)。
|
||||
|
||||
这有助于投票小部件网站识别用户,以跟踪他们已经投票的选项,并防止用户多次投票给同一选项。
|
||||
|
||||
请注意,用户可以通过使用不同的浏览器或在不同的设备上绕过此问题。防止这种滥用的唯一方法是进行用户身份验证。
|
||||
|
||||
### 渲染投票选项
|
||||
|
||||
轮询结果由多个不同宽度的条形图组成,并且有多种渲染此类结果的方法。值得讨论渲染不同宽度条形图的各种方法。
|
||||
|
||||
#### 完整条形图代表什么
|
||||
|
||||
一个完整的条形图可以有两种常见的表示方式:
|
||||
|
||||
1. 全宽表示所有响应的 100%。如果一个选项占总数的 X%,它将占据容器宽度的 X%。
|
||||
* 选项比例的准确表示
|
||||
* 如果比例非常均匀并且有很多选项的百分比很低,则不好。难以辨别,因为许多条形图会非常短
|
||||
2. 投票最多的选项以全宽呈现,其他选项是其比例。例如,投票最多的选项占总票数的 40%,但将占据整个容器宽度。如果另一个选项是 20%,它将占据一半的宽度。
|
||||
* 用于突出显示选项之间的相对差异。
|
||||
* 可能会给观看者一个错误的印象,即投票最多的选项的比例高于实际比例。
|
||||
|
||||
第一个选项是更常见的选项,Reddit 和 Twitter 都在使用。
|
||||
|
||||
{/* TODO: 添加演示来说明 */}
|
||||
|
||||
#### 渲染动态宽度的条形图
|
||||
|
||||
一个选项的投票数除以总投票数将是渲染条形图的全宽的比例。例如,一个选项的 400 票除以总共 1000 票将意味着该条形图应渲染全宽的 40%。由于宽度的可能值有无数个,因此使用静态 CSS 类来渲染特定宽度的条形图是不切实际的。更好的方法是使用在渲染期间动态生成的内联样式。
|
||||
|
||||
**1. 使用 CSS `width` 内联样式**:这是最常见的方法,唯一的小缺点是,如果需要对条形图的展开/收缩进行动画处理,则 `width` 属性的动画比 `transform` 慢。但是,该小部件大多是静态的,因此动画问题基本不存在。
|
||||
|
||||
```html
|
||||
<div style="width: 40%">React</div>
|
||||
<div style="width: 30%">Vue</div>
|
||||
```
|
||||
|
||||
**2. 使用 `transform: scaleX()` 样式**:此方法涉及水平缩放元素。
|
||||
|
||||
```html
|
||||
<div style="transform: scaleX(40%)">React</div>
|
||||
<div style="transform: scaleX(30%)">Vue</div>
|
||||
```
|
||||
|
||||
请注意,`scaleX()` 也会转换其中的内容,并使其水平压缩。
|
||||
|
||||
我们应该使用小部件全宽的百分比,而不是在页面加载时计算一次的硬编码像素值,这样,如果小部件被调整大小,条形图的宽度将被更新。可以使用 `width` 和 `transform: scaleX()` 来实现百分比宽度。
|
||||
|
||||
如果我们对一些精度损失没有问题,那么我们可以有 101 个类名,用于 0 到 100 的百分比。但总的来说,这不是一个好方法,内联样式是首选方法。
|
||||
|
||||
### 用户体验
|
||||
|
||||
* 当轮询仍在加载时,不要显示微调器,而是在条形图的形状中使用 [shimmer 加载效果](https://docs.flutter.dev/cookbook/effects/shimmer-loading) 来提示这是一个轮询,并减少轮询加载后的布局抖动。
|
||||
* 在用户投票之前应隐藏轮询结果,以减少偏差。
|
||||
* 考虑为只想查看结果而不想投票的人提供“查看回复”功能。
|
||||
|
||||
### 性能
|
||||
|
||||
#### 快速渲染
|
||||
|
||||
如前所述,为了实现结果的快速渲染,服务器 API 应该返回表格化的结果,而不是原始结果,这样客户端就不需要对结果进行任何表格化处理。
|
||||
|
||||
更倾向于使用服务器端渲染进行初始加载,而不是通过 AJAX `fetch` 轮询结果,以实现快速初始加载。
|
||||
|
||||
#### 通过乐观更新实现快速交互
|
||||
|
||||
乐观更新是一种技术,浏览器在服务器请求发出后会显示新的 UI 状态,甚至在收到服务器的响应之前。由于客户端在初始加载期间拥有当前结果,因此可以在客户端增加新投票的选项并计算所有条形图的新比例。
|
||||
|
||||
但是,也有一些注意事项:
|
||||
|
||||
* 这种优化对于投票数很少的投票更有效,因为每次新投票都会对视觉结果产生明显的影响。对于已经有很多投票(>500)的投票,额外的投票不会导致宽度有明显差异。对于此类投票,可以跳过乐观更新。
|
||||
* 如果投票很受欢迎,人们不断对其进行投票,客户端计算可能会过时,因为服务器响应将包含自首次加载该小部件以来许多新的投票。
|
||||
|
||||
在大多数情况下,客户端可以先渲染乐观更新,然后使用来自服务器的最新结果再次渲染。
|
||||
|
||||
#### 可扩展性问题
|
||||
|
||||
鉴于最多只有 6 个选项,我们不会遇到渲染太多选项和导致 DOM 尺寸过大的问题。但如果遇到,我们可以在一个具有最大高度的容器中使用虚拟化列表,以防止组件过高,并且仅渲染容器内的屏幕选项。
|
||||
|
||||
### 可访问性
|
||||
|
||||
#### 屏幕阅读器
|
||||
|
||||
轮询小部件本质上是非常直观的 UI 元素,我们需要特别注意,以确保依赖屏幕阅读器的用户仍然可以理解屏幕上显示的内容。
|
||||
|
||||
* 屏幕阅读器用户将不知道条的长度,因此需要使用 `aria-label`、`title`、`aria-describedby` 来指示轮询选项的名称、投票数和百分比(如果它们不在呈现的视觉输出中)。
|
||||
* 使用 `aria-live` 来播报客户端收到服务器响应时结果值的任何更改的更新。
|
||||
* 选项的 ARIA 角色:对于只能选择一个选项的轮询,使用 `role="radiogroup"` 和 `role="radio"`。
|
||||
|
||||
#### 键盘交互
|
||||
|
||||
* 建议使用 `<button>` 渲染轮询选项,但如果出于某种原因要使用 `<div>`,则应通过添加 `tabindex="0"` 和 `role="button"` 属性使其可聚焦。
|
||||
|
||||
### 网络
|
||||
|
||||
* 如果有人快速连续投票/取消投票,请求响应可能会乱序
|
||||
* 跟踪最新的响应并忽略过时的响应。
|
||||
* 如果 API 提交失败,在 UI 中显示错误。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
如果需要对轮询中的字符串进行 i18n,尤其是来自轮询创建者之外的字符串(例如 `aria-label`),`iframe` 嵌入 URL 可以接受语言的查询参数,并且由网站所有者提供正确的语言。
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
* [Facebook Like Button](https://developers.facebook.com/docs/plugins/like-button/)
|
||||
* [Twitter's Embedded Tweets](https://developer.twitter.com/en/docs/twitter-for-websites/embedded-tweets/overview)
|
||||
* [Disqus Universal Embed Code](https://help.disqus.com/en/articles/1717112-universal-embed-code)
|
||||
|
||||
{/* TODO: Talk about security like CORS, CSRF, CSP */}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "176eac4a",
|
||||
"excerpt": "b60a2878"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"45c8a54c",
|
||||
"9ec6d64c",
|
||||
"b8007f11",
|
||||
"f2ed0fce",
|
||||
"19080e"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"45c8a54c",
|
||||
"9ec6d64c",
|
||||
"b8007f11",
|
||||
"f2ed0fce",
|
||||
"19080e"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
title: 富文本编辑器
|
||||
excerpt: 设计一个像 Medium 和 Gmail 这样的网站上使用的富文本编辑器组件
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个可扩展的富文本编辑器组件,允许用户创建和编辑具有各种格式选项的文本。
|
||||
|
||||
### 真实案例
|
||||
|
||||
* [Lexical](https://lexical.dev/)
|
||||
* [Tiptap](https://tiptap.dev/)
|
||||
* [Slate](http://slatejs.org/)
|
||||
* [Quill](https://quilljs.com/)
|
||||
* [Draft.js](https://draftjs.org/) (Lexical 的前身)
|
||||
|
||||
富文本编辑器也常被称为 WYSIWYG 编辑器(“所见即所得”),因为您可以直接格式化文本,而无需使用特殊的标记或语法。
|
||||
|
||||
**注意**:本篇深入探讨了富文本编辑,涵盖的内容超出了面试的典型要求。但是,此处讨论的概念在富文本编辑之外也很有价值,提供了可以增强您设计复杂 UI 应用程序的能力的见解。
|
||||
|
|
@ -0,0 +1,728 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"84219ad1",
|
||||
"2d8334b2",
|
||||
"a8819a0",
|
||||
"2b07307e",
|
||||
"fd789846",
|
||||
"ea538773",
|
||||
"c8571fe",
|
||||
"74896fa5",
|
||||
"b7e8295b",
|
||||
"46a12803",
|
||||
"765baaec",
|
||||
"db6b7240",
|
||||
"8826e07b",
|
||||
"fca1c22c",
|
||||
"758589c",
|
||||
"53a6c275",
|
||||
"940c8cd7",
|
||||
"2a7816d0",
|
||||
"67c64d35",
|
||||
"5ff8a7ae",
|
||||
"bdde2362",
|
||||
"b51e87f3",
|
||||
"308bd7a4",
|
||||
"cee970cc",
|
||||
"73de8ab9",
|
||||
"aaa86b3f",
|
||||
"731feab8",
|
||||
"e0ecb497",
|
||||
"b3eb0c9d",
|
||||
"751530cd",
|
||||
"fc2c72",
|
||||
"b89c3952",
|
||||
"4c140da8",
|
||||
"6daa9ae0",
|
||||
"e4faf450",
|
||||
"e1191aaf",
|
||||
"eb1948a5",
|
||||
"ea743acd",
|
||||
"36aeb6ec",
|
||||
"6a9702f",
|
||||
"242bca9",
|
||||
"6ba1cbc9",
|
||||
"99a6bc43",
|
||||
"6e0e157c",
|
||||
"52193adf",
|
||||
"c011f13e",
|
||||
"eef994cb",
|
||||
"560f88c7",
|
||||
"70530ea4",
|
||||
"cbf729c6",
|
||||
"a22a6238",
|
||||
"149b5642",
|
||||
"99629492",
|
||||
"8b7b3ea0",
|
||||
"e71dfa89",
|
||||
"d0d0a3",
|
||||
"4d35f99c",
|
||||
"9bcfc680",
|
||||
"8cde1814",
|
||||
"2b23be0b",
|
||||
"2ff782a8",
|
||||
"d790e061",
|
||||
"7695f568",
|
||||
"4785ff7",
|
||||
"c8f57544",
|
||||
"687bfc4a",
|
||||
"63b4c40c",
|
||||
"4fbbea83",
|
||||
"d6429e2f",
|
||||
"57c9d832",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"447a28a9",
|
||||
"b4435991",
|
||||
"73b71a34",
|
||||
"bb6f5508",
|
||||
"9a3eb33",
|
||||
"cf0ae973",
|
||||
"9e92c1fd",
|
||||
"e9cd979",
|
||||
"5151eb92",
|
||||
"4501a02a",
|
||||
"c5f28d7",
|
||||
"22261885",
|
||||
"e00e4662",
|
||||
"373d5ab6",
|
||||
"9dcfceb6",
|
||||
"7171b2e",
|
||||
"d8b2b7e0",
|
||||
"93e74cc4",
|
||||
"473ba3a1",
|
||||
"2f4da91f",
|
||||
"e73eeff2",
|
||||
"f71c46db",
|
||||
"3f91d067",
|
||||
"c46bbe54",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"36c840ec",
|
||||
"66b031c9",
|
||||
"6af706af",
|
||||
"79347a3a",
|
||||
"1b8d28d0",
|
||||
"c468dd57",
|
||||
"845ce6dc",
|
||||
"17905189",
|
||||
"20e508",
|
||||
"845cbc38",
|
||||
"1944649a",
|
||||
"9431f0a",
|
||||
"d1d111ed",
|
||||
"69e31667",
|
||||
"1c00335f",
|
||||
"69bb9004",
|
||||
"7265cfae",
|
||||
"17a0e546",
|
||||
"97cd5f0f",
|
||||
"ee46a086",
|
||||
"2d5eabc7",
|
||||
"dbdc90dc",
|
||||
"ede8fd23",
|
||||
"3792669e",
|
||||
"cc5509b0",
|
||||
"f9610b5d",
|
||||
"42e89bf7",
|
||||
"cb1021f1",
|
||||
"a7c186d9",
|
||||
"a9e9e84a",
|
||||
"1de37f02",
|
||||
"691090ee",
|
||||
"cc24603b",
|
||||
"d1dbd62c",
|
||||
"2eb08e25",
|
||||
"26940cd0",
|
||||
"e845a2c5",
|
||||
"2f63060c",
|
||||
"675b8e0f",
|
||||
"87deb665",
|
||||
"963b7da8",
|
||||
"d37b5b85",
|
||||
"7a3260c1",
|
||||
"edd8ba1a",
|
||||
"c7b08369",
|
||||
"dd8ca975",
|
||||
"d79e7165",
|
||||
"c6c27c51",
|
||||
"d6f33a57",
|
||||
"bd4233f9",
|
||||
"5c8f0519",
|
||||
"e77fbc1f",
|
||||
"b94f1761",
|
||||
"f0249af6",
|
||||
"85023c86",
|
||||
"d58f8f34",
|
||||
"d5fea2ad",
|
||||
"93c00c56",
|
||||
"a929b97b",
|
||||
"1f9a7b72",
|
||||
"45184b80",
|
||||
"9551f510",
|
||||
"a81d5714",
|
||||
"b388a0ca",
|
||||
"bc899567",
|
||||
"595e9222",
|
||||
"aaa742b7",
|
||||
"23952df5",
|
||||
"eb878d81",
|
||||
"778a289b",
|
||||
"c720da99",
|
||||
"68225e8b",
|
||||
"24ccd087",
|
||||
"9ec45846",
|
||||
"348831dd",
|
||||
"895cb32a",
|
||||
"f99756c0",
|
||||
"687bfc4a",
|
||||
"e035c780",
|
||||
"59c4b99a",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"7386b87d",
|
||||
"5e011b45",
|
||||
"64206312",
|
||||
"a2565ce4",
|
||||
"e5b7b8ad",
|
||||
"557c78a2",
|
||||
"7a15817a",
|
||||
"111202a1",
|
||||
"ded7ad90",
|
||||
"c9db5533",
|
||||
"b8d7ae8e",
|
||||
"140f4a00",
|
||||
"a1d561e5",
|
||||
"3e13aac",
|
||||
"365a41b",
|
||||
"8e48d83",
|
||||
"8bc26a1b",
|
||||
"5eb2cae6",
|
||||
"da7f2ab6",
|
||||
"a7ec6e77",
|
||||
"ad3f0052",
|
||||
"e903c848",
|
||||
"4e0ea4a3",
|
||||
"a4d4c53e",
|
||||
"19d63165",
|
||||
"3746cac5",
|
||||
"7a0849e1",
|
||||
"6a5c3b43",
|
||||
"70ae1747",
|
||||
"82de5361",
|
||||
"2e71192e",
|
||||
"eff72727",
|
||||
"9135dcad",
|
||||
"7379202f",
|
||||
"c353b149",
|
||||
"630d0f3f",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"9b6ce116",
|
||||
"ef4e9c89",
|
||||
"31fb36e6",
|
||||
"de2467af",
|
||||
"9e6d702",
|
||||
"aa63189d",
|
||||
"e9697260",
|
||||
"2c2bbf3b",
|
||||
"958683e7",
|
||||
"ef6d9026",
|
||||
"214f4191",
|
||||
"15c318e7",
|
||||
"cad17d5c",
|
||||
"be4cbb2",
|
||||
"8e41d040",
|
||||
"d3ad7758",
|
||||
"f80956f0",
|
||||
"37e9e919",
|
||||
"2bb816a5",
|
||||
"c03c3ad4",
|
||||
"a24ace6a",
|
||||
"e818910e",
|
||||
"d810b803",
|
||||
"9ba06a49",
|
||||
"ba6d369e",
|
||||
"40118c",
|
||||
"20345b27",
|
||||
"a9e5a60f",
|
||||
"ea23e31c",
|
||||
"8ce2be1d",
|
||||
"4e884f74",
|
||||
"81a1880c",
|
||||
"c8a84706",
|
||||
"e19293ea",
|
||||
"cce340d6",
|
||||
"a0a75c21",
|
||||
"67fedf61",
|
||||
"216eef18",
|
||||
"76714e3b",
|
||||
"f22cb0bd",
|
||||
"bb499ff4",
|
||||
"d34da153",
|
||||
"7c775782",
|
||||
"943a563f",
|
||||
"8f294868",
|
||||
"2fc45b1a",
|
||||
"3fb2970b",
|
||||
"4b558bba",
|
||||
"43179e32",
|
||||
"21ce2033",
|
||||
"b91d3311",
|
||||
"7cd5a160",
|
||||
"5ee77962",
|
||||
"ebf9199a",
|
||||
"54df750",
|
||||
"f921206f",
|
||||
"6cacf4df",
|
||||
"46241156",
|
||||
"71c27d64",
|
||||
"96cae591",
|
||||
"9124920d",
|
||||
"f814b0ca",
|
||||
"dc6580d",
|
||||
"d7da4cbe",
|
||||
"364de18d",
|
||||
"1485ee0d",
|
||||
"ac3c03f4",
|
||||
"3feae981",
|
||||
"e031d173",
|
||||
"1549dada",
|
||||
"b7f310b6",
|
||||
"77983f18",
|
||||
"525d67da",
|
||||
"e76261a4",
|
||||
"ea680c1f",
|
||||
"402cff92",
|
||||
"af9a0e45",
|
||||
"a42c8aaf",
|
||||
"66813d69",
|
||||
"ba77da9b",
|
||||
"72d9e16d",
|
||||
"5b83834a",
|
||||
"706ed724",
|
||||
"4fc4a7bd",
|
||||
"25a46767",
|
||||
"472eccb6",
|
||||
"d88277c8",
|
||||
"ccdc7eb",
|
||||
"3c91f1d6",
|
||||
"9b7500d1",
|
||||
"6c0a169a",
|
||||
"bc8b1c2c",
|
||||
"227318f2",
|
||||
"4270402f",
|
||||
"9ced2abc",
|
||||
"ad621b97",
|
||||
"ca896f6d",
|
||||
"4f3725ac",
|
||||
"cb24cbea",
|
||||
"fa81d4dc",
|
||||
"6eebca2",
|
||||
"308b941e",
|
||||
"4bd4397a",
|
||||
"3e3c7d98",
|
||||
"bbe6f7b4",
|
||||
"571f328a",
|
||||
"b918a992",
|
||||
"abce439e",
|
||||
"4bac5372",
|
||||
"1fbb61a",
|
||||
"f31bd57f",
|
||||
"ba26a82e",
|
||||
"3f79c9fa",
|
||||
"782f36e2",
|
||||
"9846f084",
|
||||
"64c27e31",
|
||||
"4fa5c531",
|
||||
"66605fca",
|
||||
"ce4830f1",
|
||||
"91587678",
|
||||
"aa95613b",
|
||||
"ec90a24f",
|
||||
"c2ed631b",
|
||||
"46e2fa8c",
|
||||
"d601f279",
|
||||
"6430756d",
|
||||
"e68c5211",
|
||||
"90a2c3d1",
|
||||
"5defc23d",
|
||||
"df29b84a",
|
||||
"b8f2b1e1",
|
||||
"bf25d839",
|
||||
"6c4959a5",
|
||||
"15390d98",
|
||||
"a1f44055",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"de63bbd3"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"84219ad1",
|
||||
"2d8334b2",
|
||||
"a8819a0",
|
||||
"2b07307e",
|
||||
"fd789846",
|
||||
"ea538773",
|
||||
"c8571fe",
|
||||
"74896fa5",
|
||||
"b7e8295b",
|
||||
"46a12803",
|
||||
"765baaec",
|
||||
"db6b7240",
|
||||
"8826e07b",
|
||||
"fca1c22c",
|
||||
"758589c",
|
||||
"53a6c275",
|
||||
"940c8cd7",
|
||||
"2a7816d0",
|
||||
"67c64d35",
|
||||
"5ff8a7ae",
|
||||
"bdde2362",
|
||||
"b51e87f3",
|
||||
"308bd7a4",
|
||||
"cee970cc",
|
||||
"73de8ab9",
|
||||
"aaa86b3f",
|
||||
"731feab8",
|
||||
"e0ecb497",
|
||||
"b3eb0c9d",
|
||||
"751530cd",
|
||||
"fc2c72",
|
||||
"b89c3952",
|
||||
"4c140da8",
|
||||
"6daa9ae0",
|
||||
"e4faf450",
|
||||
"e1191aaf",
|
||||
"eb1948a5",
|
||||
"ea743acd",
|
||||
"36aeb6ec",
|
||||
"6a9702f",
|
||||
"242bca9",
|
||||
"6ba1cbc9",
|
||||
"99a6bc43",
|
||||
"6e0e157c",
|
||||
"52193adf",
|
||||
"c011f13e",
|
||||
"eef994cb",
|
||||
"560f88c7",
|
||||
"70530ea4",
|
||||
"cbf729c6",
|
||||
"a22a6238",
|
||||
"149b5642",
|
||||
"99629492",
|
||||
"8b7b3ea0",
|
||||
"e71dfa89",
|
||||
"d0d0a3",
|
||||
"4d35f99c",
|
||||
"9bcfc680",
|
||||
"8cde1814",
|
||||
"2b23be0b",
|
||||
"2ff782a8",
|
||||
"d790e061",
|
||||
"7695f568",
|
||||
"4785ff7",
|
||||
"c8f57544",
|
||||
"687bfc4a",
|
||||
"63b4c40c",
|
||||
"4fbbea83",
|
||||
"d6429e2f",
|
||||
"57c9d832",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"447a28a9",
|
||||
"b4435991",
|
||||
"73b71a34",
|
||||
"bb6f5508",
|
||||
"9a3eb33",
|
||||
"cf0ae973",
|
||||
"9e92c1fd",
|
||||
"e9cd979",
|
||||
"5151eb92",
|
||||
"4501a02a",
|
||||
"c5f28d7",
|
||||
"22261885",
|
||||
"e00e4662",
|
||||
"373d5ab6",
|
||||
"9dcfceb6",
|
||||
"7171b2e",
|
||||
"d8b2b7e0",
|
||||
"93e74cc4",
|
||||
"473ba3a1",
|
||||
"2f4da91f",
|
||||
"e73eeff2",
|
||||
"f71c46db",
|
||||
"3f91d067",
|
||||
"c46bbe54",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"36c840ec",
|
||||
"66b031c9",
|
||||
"6af706af",
|
||||
"79347a3a",
|
||||
"1b8d28d0",
|
||||
"c468dd57",
|
||||
"845ce6dc",
|
||||
"17905189",
|
||||
"20e508",
|
||||
"845cbc38",
|
||||
"1944649a",
|
||||
"9431f0a",
|
||||
"d1d111ed",
|
||||
"69e31667",
|
||||
"1c00335f",
|
||||
"69bb9004",
|
||||
"7265cfae",
|
||||
"17a0e546",
|
||||
"97cd5f0f",
|
||||
"ee46a086",
|
||||
"2d5eabc7",
|
||||
"dbdc90dc",
|
||||
"ede8fd23",
|
||||
"3792669e",
|
||||
"cc5509b0",
|
||||
"f9610b5d",
|
||||
"42e89bf7",
|
||||
"cb1021f1",
|
||||
"a7c186d9",
|
||||
"a9e9e84a",
|
||||
"1de37f02",
|
||||
"691090ee",
|
||||
"cc24603b",
|
||||
"d1dbd62c",
|
||||
"2eb08e25",
|
||||
"26940cd0",
|
||||
"e845a2c5",
|
||||
"2f63060c",
|
||||
"675b8e0f",
|
||||
"87deb665",
|
||||
"963b7da8",
|
||||
"d37b5b85",
|
||||
"7a3260c1",
|
||||
"edd8ba1a",
|
||||
"c7b08369",
|
||||
"dd8ca975",
|
||||
"d79e7165",
|
||||
"c6c27c51",
|
||||
"d6f33a57",
|
||||
"bd4233f9",
|
||||
"5c8f0519",
|
||||
"e77fbc1f",
|
||||
"b94f1761",
|
||||
"f0249af6",
|
||||
"85023c86",
|
||||
"d58f8f34",
|
||||
"d5fea2ad",
|
||||
"93c00c56",
|
||||
"a929b97b",
|
||||
"1f9a7b72",
|
||||
"45184b80",
|
||||
"9551f510",
|
||||
"a81d5714",
|
||||
"b388a0ca",
|
||||
"bc899567",
|
||||
"595e9222",
|
||||
"aaa742b7",
|
||||
"23952df5",
|
||||
"eb878d81",
|
||||
"778a289b",
|
||||
"c720da99",
|
||||
"68225e8b",
|
||||
"24ccd087",
|
||||
"9ec45846",
|
||||
"348831dd",
|
||||
"895cb32a",
|
||||
"f99756c0",
|
||||
"687bfc4a",
|
||||
"e035c780",
|
||||
"59c4b99a",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"7386b87d",
|
||||
"5e011b45",
|
||||
"64206312",
|
||||
"a2565ce4",
|
||||
"e5b7b8ad",
|
||||
"557c78a2",
|
||||
"7a15817a",
|
||||
"111202a1",
|
||||
"ded7ad90",
|
||||
"c9db5533",
|
||||
"b8d7ae8e",
|
||||
"140f4a00",
|
||||
"a1d561e5",
|
||||
"3e13aac",
|
||||
"365a41b",
|
||||
"8e48d83",
|
||||
"8bc26a1b",
|
||||
"5eb2cae6",
|
||||
"da7f2ab6",
|
||||
"a7ec6e77",
|
||||
"ad3f0052",
|
||||
"e903c848",
|
||||
"4e0ea4a3",
|
||||
"a4d4c53e",
|
||||
"19d63165",
|
||||
"3746cac5",
|
||||
"7a0849e1",
|
||||
"6a5c3b43",
|
||||
"70ae1747",
|
||||
"82de5361",
|
||||
"2e71192e",
|
||||
"eff72727",
|
||||
"9135dcad",
|
||||
"7379202f",
|
||||
"c353b149",
|
||||
"630d0f3f",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"9b6ce116",
|
||||
"ef4e9c89",
|
||||
"31fb36e6",
|
||||
"de2467af",
|
||||
"9e6d702",
|
||||
"aa63189d",
|
||||
"e9697260",
|
||||
"2c2bbf3b",
|
||||
"958683e7",
|
||||
"ef6d9026",
|
||||
"214f4191",
|
||||
"15c318e7",
|
||||
"cad17d5c",
|
||||
"be4cbb2",
|
||||
"8e41d040",
|
||||
"d3ad7758",
|
||||
"f80956f0",
|
||||
"37e9e919",
|
||||
"2bb816a5",
|
||||
"c03c3ad4",
|
||||
"a24ace6a",
|
||||
"e818910e",
|
||||
"d810b803",
|
||||
"9ba06a49",
|
||||
"ba6d369e",
|
||||
"40118c",
|
||||
"20345b27",
|
||||
"a9e5a60f",
|
||||
"ea23e31c",
|
||||
"8ce2be1d",
|
||||
"4e884f74",
|
||||
"81a1880c",
|
||||
"c8a84706",
|
||||
"e19293ea",
|
||||
"cce340d6",
|
||||
"a0a75c21",
|
||||
"67fedf61",
|
||||
"216eef18",
|
||||
"76714e3b",
|
||||
"f22cb0bd",
|
||||
"bb499ff4",
|
||||
"d34da153",
|
||||
"7c775782",
|
||||
"943a563f",
|
||||
"8f294868",
|
||||
"2fc45b1a",
|
||||
"3fb2970b",
|
||||
"4b558bba",
|
||||
"43179e32",
|
||||
"21ce2033",
|
||||
"b91d3311",
|
||||
"7cd5a160",
|
||||
"5ee77962",
|
||||
"ebf9199a",
|
||||
"54df750",
|
||||
"f921206f",
|
||||
"6cacf4df",
|
||||
"46241156",
|
||||
"71c27d64",
|
||||
"96cae591",
|
||||
"9124920d",
|
||||
"f814b0ca",
|
||||
"dc6580d",
|
||||
"d7da4cbe",
|
||||
"364de18d",
|
||||
"1485ee0d",
|
||||
"ac3c03f4",
|
||||
"3feae981",
|
||||
"e031d173",
|
||||
"1549dada",
|
||||
"b7f310b6",
|
||||
"77983f18",
|
||||
"525d67da",
|
||||
"e76261a4",
|
||||
"ea680c1f",
|
||||
"402cff92",
|
||||
"af9a0e45",
|
||||
"a42c8aaf",
|
||||
"66813d69",
|
||||
"ba77da9b",
|
||||
"72d9e16d",
|
||||
"5b83834a",
|
||||
"706ed724",
|
||||
"4fc4a7bd",
|
||||
"25a46767",
|
||||
"472eccb6",
|
||||
"d88277c8",
|
||||
"ccdc7eb",
|
||||
"3c91f1d6",
|
||||
"9b7500d1",
|
||||
"6c0a169a",
|
||||
"bc8b1c2c",
|
||||
"227318f2",
|
||||
"4270402f",
|
||||
"9ced2abc",
|
||||
"ad621b97",
|
||||
"ca896f6d",
|
||||
"4f3725ac",
|
||||
"cb24cbea",
|
||||
"fa81d4dc",
|
||||
"6eebca2",
|
||||
"308b941e",
|
||||
"4bd4397a",
|
||||
"3e3c7d98",
|
||||
"bbe6f7b4",
|
||||
"571f328a",
|
||||
"b918a992",
|
||||
"abce439e",
|
||||
"4bac5372",
|
||||
"1fbb61a",
|
||||
"f31bd57f",
|
||||
"ba26a82e",
|
||||
"3f79c9fa",
|
||||
"782f36e2",
|
||||
"9846f084",
|
||||
"64c27e31",
|
||||
"4fa5c531",
|
||||
"66605fca",
|
||||
"ce4830f1",
|
||||
"91587678",
|
||||
"aa95613b",
|
||||
"ec90a24f",
|
||||
"c2ed631b",
|
||||
"46e2fa8c",
|
||||
"d601f279",
|
||||
"6430756d",
|
||||
"e68c5211",
|
||||
"90a2c3d1",
|
||||
"5defc23d",
|
||||
"df29b84a",
|
||||
"b8f2b1e1",
|
||||
"bf25d839",
|
||||
"6c4959a5",
|
||||
"15390d98",
|
||||
"a1f44055",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"de63bbd3"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "c7e58b57",
|
||||
"excerpt": "ce2f3df5"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"9a525814",
|
||||
"a0be6f38",
|
||||
"edb9d38e",
|
||||
"9ec6d64c",
|
||||
"4d97b18"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"9a525814",
|
||||
"a0be6f38",
|
||||
"edb9d38e",
|
||||
"9ec6d64c",
|
||||
"4d97b18"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
title: 旅行预订(例如 Airbnb)
|
||||
excerpt: 设计一个类似 Airbnb 和 Expedia 的旅行预订网站
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个旅行预订网站,允许用户搜索住宿并进行预订。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* [Airbnb](https://airbnb.com)
|
||||
* [Booking.com](https://www.booking.com)
|
||||
* [Expedia](https://www.expedia.com)
|
||||
* [TripAdvisor](https://www.tripadvisor.com)
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"44ddb1b3",
|
||||
"9181a833",
|
||||
"28468d0c",
|
||||
"b7e8295b",
|
||||
"d7f8c478",
|
||||
"16562442",
|
||||
"98783b14",
|
||||
"eed82532",
|
||||
"beabcfe3",
|
||||
"687bfc4a",
|
||||
"ed4d0eaf",
|
||||
"d08b90cd",
|
||||
"4b4b5c08",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"78033f27",
|
||||
"7f37f262",
|
||||
"9f28c7f1",
|
||||
"5df0cd3a",
|
||||
"285295f4",
|
||||
"d1f53836",
|
||||
"1ad1a80b",
|
||||
"c219a0ba",
|
||||
"e133090b",
|
||||
"3a8f801a",
|
||||
"228827f6",
|
||||
"ce5597b9",
|
||||
"46e62bc2",
|
||||
"c0068cca",
|
||||
"692bb1b8",
|
||||
"acd0dd0a",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"a9986d1d",
|
||||
"f7edb6ef",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"fe2b4ffc",
|
||||
"b822a282",
|
||||
"c4664d03",
|
||||
"78bd7b54",
|
||||
"cb2fa491",
|
||||
"ba1dca42",
|
||||
"734da1f4",
|
||||
"c6c6159d",
|
||||
"4a3436af",
|
||||
"5db7e569",
|
||||
"6462a681",
|
||||
"ae64e5",
|
||||
"d1699485",
|
||||
"205911b8",
|
||||
"9f2c9b4",
|
||||
"4b44efc0",
|
||||
"b6b13e5e",
|
||||
"ff343472",
|
||||
"65a17d53",
|
||||
"d6d05b77",
|
||||
"30539191",
|
||||
"fd21f6a1",
|
||||
"9d3cef9f",
|
||||
"9e7d9da3",
|
||||
"b48870ea",
|
||||
"7ed6db72",
|
||||
"b0d43ac8",
|
||||
"cb2fa491",
|
||||
"af36f9f7",
|
||||
"65a17d53",
|
||||
"2b3e3324",
|
||||
"dd26e98c",
|
||||
"d0a45794",
|
||||
"cb2fa491",
|
||||
"10407c83",
|
||||
"fdd2f116",
|
||||
"65a17d53",
|
||||
"2036117f",
|
||||
"f9491217",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"eedf8260",
|
||||
"10161aaa",
|
||||
"f4026992",
|
||||
"2c91fbe1",
|
||||
"d71d0f86",
|
||||
"c559e07",
|
||||
"605734f9",
|
||||
"701f29c5",
|
||||
"922ccdaf",
|
||||
"557f01e4",
|
||||
"a7894829",
|
||||
"bbe8fb9a",
|
||||
"7e3d165",
|
||||
"427d5d55",
|
||||
"df29b84a",
|
||||
"c50b24e7",
|
||||
"df2bd9c7",
|
||||
"9846f084",
|
||||
"3e727f88",
|
||||
"80b51ddd",
|
||||
"733f2acb",
|
||||
"3d86a87f",
|
||||
"7224399e",
|
||||
"8cc6acc6",
|
||||
"18b4d208",
|
||||
"1d0491a0",
|
||||
"3d9812bd",
|
||||
"77d5c00",
|
||||
"7daeb8aa",
|
||||
"6b3731e3",
|
||||
"3edfbeb2",
|
||||
"c957bf04",
|
||||
"5f25546f",
|
||||
"1ef8c07c",
|
||||
"8afc011e",
|
||||
"ee2e1268",
|
||||
"427ba9fe",
|
||||
"ca8eb92",
|
||||
"38a58259",
|
||||
"afd3b51",
|
||||
"e0facd8",
|
||||
"ab34bd00",
|
||||
"134be226",
|
||||
"c3e835ed",
|
||||
"2aabbc35",
|
||||
"66605fca",
|
||||
"867a261d",
|
||||
"44b27fd4",
|
||||
"2a3fd519",
|
||||
"c11008b0",
|
||||
"cb6c89ec",
|
||||
"cff248fb"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"44ddb1b3",
|
||||
"9181a833",
|
||||
"28468d0c",
|
||||
"b7e8295b",
|
||||
"d7f8c478",
|
||||
"16562442",
|
||||
"98783b14",
|
||||
"eed82532",
|
||||
"beabcfe3",
|
||||
"687bfc4a",
|
||||
"ed4d0eaf",
|
||||
"d08b90cd",
|
||||
"4b4b5c08",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"78033f27",
|
||||
"7f37f262",
|
||||
"9f28c7f1",
|
||||
"5df0cd3a",
|
||||
"285295f4",
|
||||
"d1f53836",
|
||||
"1ad1a80b",
|
||||
"c219a0ba",
|
||||
"e133090b",
|
||||
"3a8f801a",
|
||||
"228827f6",
|
||||
"ce5597b9",
|
||||
"46e62bc2",
|
||||
"c0068cca",
|
||||
"692bb1b8",
|
||||
"acd0dd0a",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"a9986d1d",
|
||||
"f7edb6ef",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"fe2b4ffc",
|
||||
"b822a282",
|
||||
"c4664d03",
|
||||
"78bd7b54",
|
||||
"cb2fa491",
|
||||
"ba1dca42",
|
||||
"734da1f4",
|
||||
"c6c6159d",
|
||||
"4a3436af",
|
||||
"5db7e569",
|
||||
"6462a681",
|
||||
"ae64e5",
|
||||
"d1699485",
|
||||
"205911b8",
|
||||
"9f2c9b4",
|
||||
"4b44efc0",
|
||||
"b6b13e5e",
|
||||
"ff343472",
|
||||
"65a17d53",
|
||||
"d6d05b77",
|
||||
"30539191",
|
||||
"fd21f6a1",
|
||||
"9d3cef9f",
|
||||
"9e7d9da3",
|
||||
"b48870ea",
|
||||
"7ed6db72",
|
||||
"b0d43ac8",
|
||||
"cb2fa491",
|
||||
"af36f9f7",
|
||||
"65a17d53",
|
||||
"2b3e3324",
|
||||
"dd26e98c",
|
||||
"d0a45794",
|
||||
"cb2fa491",
|
||||
"10407c83",
|
||||
"fdd2f116",
|
||||
"65a17d53",
|
||||
"2036117f",
|
||||
"f9491217",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"eedf8260",
|
||||
"10161aaa",
|
||||
"f4026992",
|
||||
"2c91fbe1",
|
||||
"d71d0f86",
|
||||
"c559e07",
|
||||
"605734f9",
|
||||
"701f29c5",
|
||||
"922ccdaf",
|
||||
"557f01e4",
|
||||
"a7894829",
|
||||
"bbe8fb9a",
|
||||
"7e3d165",
|
||||
"427d5d55",
|
||||
"df29b84a",
|
||||
"c50b24e7",
|
||||
"df2bd9c7",
|
||||
"9846f084",
|
||||
"3e727f88",
|
||||
"80b51ddd",
|
||||
"733f2acb",
|
||||
"3d86a87f",
|
||||
"7224399e",
|
||||
"8cc6acc6",
|
||||
"18b4d208",
|
||||
"1d0491a0",
|
||||
"3d9812bd",
|
||||
"77d5c00",
|
||||
"7daeb8aa",
|
||||
"6b3731e3",
|
||||
"3edfbeb2",
|
||||
"c957bf04",
|
||||
"5f25546f",
|
||||
"1ef8c07c",
|
||||
"8afc011e",
|
||||
"ee2e1268",
|
||||
"427ba9fe",
|
||||
"ca8eb92",
|
||||
"38a58259",
|
||||
"afd3b51",
|
||||
"e0facd8",
|
||||
"ab34bd00",
|
||||
"134be226",
|
||||
"c3e835ed",
|
||||
"2aabbc35",
|
||||
"66605fca",
|
||||
"867a261d",
|
||||
"44b27fd4",
|
||||
"2a3fd519",
|
||||
"c11008b0",
|
||||
"cb6c89ec",
|
||||
"cff248fb"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
## 需求探索
|
||||
|
||||
### 需要支持哪些核心功能?
|
||||
|
||||
* 搜索和浏览住宿列表。
|
||||
* 查看住宿详情,例如价格、位置、照片和便利设施。
|
||||
* 预订住宿。
|
||||
|
||||
### 用户人群是什么样的?
|
||||
|
||||
广泛年龄段的国际用户:美国、亚洲、欧洲等。
|
||||
|
||||
### 有哪些非功能性需求?
|
||||
|
||||
每个页面应在 2 秒内加载。与页面元素的交互应快速响应。
|
||||
|
||||
### 网站将在哪些设备上使用?
|
||||
|
||||
所有可能的设备:笔记本电脑、平板电脑、手机等。
|
||||
|
||||
### 用户是否必须登录?
|
||||
|
||||
任何人都可以搜索列表和浏览详细信息,但用户需要登录才能进行预订。
|
||||
|
||||
### 总结
|
||||
|
||||
从以上内容可以看出,该网站最重要的方面是:
|
||||
|
||||
* **搜索引擎优化**:旅游网站应旨在在搜索引擎上排名靠前,因为自然搜索是主要的发现机制之一。
|
||||
* **性能**:已知性能会影响转化率。对于旨在让客户进行购买的网站,性能非常重要。
|
||||
* **国际化**:用户来自许多不同的国家和不同的年龄组。为了吸引更大的市场,该网站应该被翻译和本地化。
|
||||
* **设备支持**:该网站应该适用于各种设备,因为用户人群非常广泛。
|
||||
|
||||
旅游预订网站的需求和性质与[电子商务网站系统设计](/questions/system-design/e-commerce-amazon)非常相似,因此解决方案之间存在高度重叠。在本解决方案中,我们将介绍旅游预订网站中未在电子商务网站上找到的独特方面。
|
||||
|
||||
***
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
{/* TODO: 架构图 */}
|
||||
|
||||
### 服务器端渲染 (SSR) 还是客户端渲染?
|
||||
|
||||
与[电子商务网站系统设计](/questions/system-design/e-commerce-amazon)一样,性能和 SEO 至关重要。因此,服务器端渲染是必须的,因为它具有性能和 SEO 优势。
|
||||
|
||||
### 单页应用程序 (SPA) 还是多页应用程序 (MPA)?
|
||||
|
||||
许多旅游网站有意在新标签页/窗口中打开列表详细信息,以便于访问搜索结果页面。由于经常打开新页面,初始加载性能比后续导航性能更重要。该网站从单页应用程序架构中获得的收益也不如最常导航的页面无法重用应用程序 shell 和现有客户端状态。
|
||||
|
||||
因此,最重要的是使用 SSR,网站是 SPA 还是 MPA 并不重要,两者都可以根据后续导航体验的重要性而定。在现代 Web 生态系统中,旅游网站是 MPA 架构仍然可行的少数产品之一。
|
||||
|
||||
然而,旅游网站有很多互动(例如,更改搜索过滤器、与地图互动、展开住宿详情等)。使用 UI 框架对于编写可维护的客户端代码至关重要。React、Vue 和 Angular 是这样做的首选。
|
||||
|
||||
对于 SSR + 交互性强的用例,我们可以利用通用/同构渲染(或带有水合的 SSR)。在通用渲染中,服务器渲染完整的初始 HTML,但此后,渲染和导航变为客户端。JavaScript 事件处理程序附加到 HTML(水合)中的交互式元素。
|
||||
|
||||
Airbnb 是使用 [Rendr](https://github.com/rendrjs/rendr) 构建通用/同构应用程序的先驱之一,Rendr 是一个在客户端和服务器上渲染 Backbone.js 应用程序的库。如今,随着 React 和 Next.js 的兴起,Rendr 已经不再使用。Next.js 是构建通用 React 驱动的 Web 应用程序的最受欢迎的选择,这些应用程序需要带有水合的服务器端渲染。
|
||||
|
||||
### 顶级旅游预订网站使用什么
|
||||
|
||||
让我们看看顶级旅游预订网站及其渲染选择:
|
||||
|
||||
| | 应用程序类型 | 渲染 | UI 框架 |
|
||||
| ----------- | ----------------- | --------- | ------------ |
|
||||
| Airbnb | SPA (某些路由) | SSR | React |
|
||||
| Booking.com | MPA | SSR | React |
|
||||
| Expedia | MPA | SSR | React |
|
||||
| TripAdvisor | SPA (某些路由) | SSR | React |
|
||||
|
||||
**世界上所有顶级的旅游预订网站都使用 SSR 和 React!** 这表明了 SSR 对于旅游网站的重要性以及 React 在行业中的主导地位。这些网站中有一半使用 MPA,可能是因为使用 SPA + SSR 组合会增加复杂性,可能不值得处理。
|
||||
|
||||
为简单起见,并且由于 SPA 在其他问题中很常见,以下讨论假定我们将使用 MPA 架构,并带有服务器端渲染 + 水合,因为需要在初始加载后进行交互。
|
||||
|
||||
**参考**
|
||||
|
||||
* [重新构建 Airbnb 的前端 | Airbnb 工程博客](https://medium.com/airbnb-engineering/rearchitecting-airbnbs-frontend-5e213efc24d2)
|
||||
* [使用 React Router v4 进行服务器渲染、代码拆分和延迟加载 | Airbnb 工程博客](https://medium.com/airbnb-engineering/server-rendering-code-splitting-and-lazy-loading-with-react-router-v4-bfe596a6af70)
|
||||
* [Rendr:在浏览器和 Node 中运行您的 Backbone 应用程序 | Airbnb 工程博客](https://medium.com/airbnb-engineering/rendr-run-your-backbone-apps-in-the-browser-and-node-a3481af49312)
|
||||
* [同构 JavaScript:Web 应用程序的未来 | Airbnb 工程博客](https://medium.com/airbnb-engineering/isomorphic-javascript-the-future-of-web-apps-10882b7a2ebc)
|
||||
* [打破单体 — Agoda.com 的模块化重新设计 | Agoda 工程与设计](https://medium.com/agoda-engineering/breaking-the-monolith-f3538d9c3ad6)
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
| 实体 | 来源 | 属于 | 字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| 搜索参数 | 用户输入(客户端) | 搜索/列表页面 | 城市/地理位置/半径 日期范围、人数、便利设施等 |
|
||||
| `ListingResults` | 服务器 | 搜索/列表页面 | `results`(`ListingItem` 列表)、`pagination`(分页元数据) |
|
||||
| `ListingItem` | 服务器 | 搜索/列表页面、详细信息页面 | `title`、`price`、`currency`、`image_urls`、`amenities`(灵活格式) |
|
||||
|
||||
我们省略了与付款和地址相关的实体,因为它们在[电子商务网站系统设计](/questions/system-design/e-commerce-amazon)中有所介绍。如果面试官希望您也关注结账流程,请提及这些实体。
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
我们将需要以下 HTTP API:
|
||||
|
||||
1. **搜索**:给定一些搜索/筛选参数,获取住宿列表。结果在地图和/或列表视图上呈现
|
||||
2. **列表详细信息**:获取住宿列表的详细信息
|
||||
3. **预订**:预订住宿
|
||||
|
||||
### 搜索住宿
|
||||
|
||||
| 属性 | 值 |
|
||||
| --- | --- |
|
||||
| HTTP 方法 | `GET` |
|
||||
| 路径 | `/search` |
|
||||
| 描述 | 返回与搜索查询匹配的住宿列表。 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| Size | number | 每页的结果数 |
|
||||
| Page | number | 要获取的页码 |
|
||||
| Guests | number | 入住的客人数量 |
|
||||
| Country | string | 用户的国家,决定货币 |
|
||||
| Location | mixed (see below) | 搜索的位置 |
|
||||
| Date range | mixed (see below) | 占用住宿的日期范围 |
|
||||
| Amenities | mixed (see below) | 便利设施标准 |
|
||||
|
||||
**位置**:`location` 字段的确切格式取决于业务需求。但总的来说,我们需要告诉服务器所需的边界,以便显示边界内的结果。有两种常见的方式来表示边界:(1)中心位置 + 半径,(2)边界坐标。
|
||||
|
||||
1. **中心位置 + 半径**:半径可以是英里/公里,中心位置可以是以下之一:
|
||||
* **自由文本搜索**:这是包含位置的任何字符串,例如“旧金山”、“纽约市”。将需要一个外部位置服务将字符串转换为地理坐标(通过地理编码)。最好在服务器上进行地理编码,以防止 API 滥用。
|
||||
* **地理位置/坐标**:此格式适用于基于地图的 UI,用户可以在其中平移/缩放地图以搜索该区域内的列表,或者当用户允许应用程序访问当前位置以搜索他们周围的列表时。
|
||||
2. **边界坐标**:对于基于地图的 UI,我们可以使用呈现地图的 4 个角的坐标作为边界区域。
|
||||
|
||||
如果网站有不同的搜索 UI,则搜索 API 可能需要支持这两种格式:
|
||||
|
||||
* 旅行网站的着陆页通常有一个搜索栏,其中包含日期范围和客人数量等强制性参数。此类 UI 将需要自由文本搜索位置格式。
|
||||
* 带有地图的结果页面将使用地理位置/边界坐标格式。
|
||||
|
||||
**日期范围**:有几种日期范围格式可供选择,而且没有明确的赢家:
|
||||
|
||||
| 格式 | 示例 | 优点 | 缺点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 数组/元组 | `['2022-12-24', '2022-12-27']` | 短 | 不明确。必须编码 |
|
||||
| 对象 | `{ check_in: '2022-12-24', check_out: '2022-12-27' }` | 明确 | 长。必须编码 |
|
||||
| 单独的查询参数 | `check_in` 和 `check_out` | 不需要编码 | 两个查询参数与一个 |
|
||||
|
||||
这三种格式中,数组格式最不明确,对象需要编码才能在 `GET` 中使用,因此建议使用单独的查询参数。
|
||||
|
||||
**便利设施**:放置便利设施的查询参数与日期范围存在相同的问题。
|
||||
|
||||
| 格式 | 示例 | 优点 | 缺点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 对象 | `{ breakfast: true, washer: true }` | 明确 | 长。必须编码 |
|
||||
| 单独的查询参数 | `breakfast` 和 `washer` | 更具可读性 | N 个查询参数与一个 |
|
||||
|
||||
对于这种情况,由于如果每个便利设施标准都是一个单独的查询参数,查询参数的数量非常多,因此对象格式更好,因为:
|
||||
|
||||
* 将每个标准放在查询参数中会使查询字符串变得非常长,并且层次结构会丢失。
|
||||
* 可能会与非便利设施字段发生查询参数名称冲突。这可以通过在所有便利设施字段前加上 `amenities_`(例如 `amenities_breakfast` 和 `amenities_washer`)来解决。
|
||||
* 此外,一些便利设施标准具有非原始值,无论如何都需要进行 URL 编码和解码。
|
||||
|
||||
关于[如何将数组和对象编码到 URL 的查询字符串中](https://stackoverflow.com/questions/6243051/how-to-pass-an-array-within-a-query-string)没有[固定的标准],并且格式往往取决于实现。需要注意的最重要的事情是服务器和客户端之间的一致格式。
|
||||
|
||||
#### 示例响应
|
||||
|
||||
```json
|
||||
{
|
||||
// 分页元数据。
|
||||
"pagination": {
|
||||
"size": 5,
|
||||
"page": 2,
|
||||
"total_pages": 15,
|
||||
"total": 74
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": 561602, // 住宿 ID。
|
||||
"title": "Great view in the Mission, 15 mins by bus downtown",
|
||||
"images": [
|
||||
"https://www.greathotels.com/img/1.jpg",
|
||||
"https://www.greathotels.com/img/2.jpg",
|
||||
"https://www.greathotels.com/img/3.jpg",
|
||||
"https://www.greathotels.com/img/4.jpg"
|
||||
],
|
||||
"rating": 4.82,
|
||||
"coordinates": {
|
||||
"latitude": 37.74403,
|
||||
"longitude": -122.41755
|
||||
},
|
||||
"price": 200,
|
||||
"currency": "USD"
|
||||
}
|
||||
// ... 更多住宿结果。
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
我们在这里使用基于偏移的分页,而不是基于游标的分页,因为:
|
||||
|
||||
1. 拥有页码对于在搜索结果之间导航和跳转到特定页面很有用。
|
||||
2. 住宿结果不会受到陈旧结果问题的困扰,因为新列表不会添加得那么快/频繁。
|
||||
3. 了解总共有多少结果很有用。
|
||||
|
||||
有关基于偏移量的分页和基于游标的分页之间的更深入比较,请参阅[新闻提要系统设计文章](/questions/system-design/news-feed-facebook)。
|
||||
|
||||
如果需要无限滚动,则可能需要基于游标的分页方法。 仍然可以使用基于偏移的分页,但客户端将需要费力地过滤掉重复的结果。
|
||||
|
||||
### 获取住宿详情
|
||||
|
||||
严格来说,如果住宿详情页面总是在新标签页中打开,那么详情数据将只需要在服务器上获取以创建初始 HTML;它不会从客户端获取,因此不需要独立的 HTTP API。
|
||||
|
||||
| 属性 | 值 |
|
||||
| ----------- | ------------------------------------------------ |
|
||||
| HTTP 方法 | `GET` |
|
||||
| 路径 | `/accommodation/{accommodationId}` |
|
||||
| 描述 | 获取住宿列表的详细信息。 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| ----------------- | ------ | --------------------------------------------- |
|
||||
| `accommodationId` | number | 要获取的住宿 ID |
|
||||
| `country` | string | 用户的国家/地区,决定货币 |
|
||||
|
||||
#### 示例响应
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 561602, // 住宿 ID。
|
||||
"title": "Great view of Brannan Street, 15 mins by bus downtown. Bed and Breakfast provided!",
|
||||
"images": [
|
||||
"https://www.greathotels.com/img/1.jpg",
|
||||
"https://www.greathotels.com/img/2.jpg",
|
||||
"https://www.greathotels.com/img/3.jpg",
|
||||
"https://www.greathotels.com/img/4.jpg"
|
||||
],
|
||||
"rating": 4.82,
|
||||
"coordinates": {
|
||||
"latitude": 37.74403,
|
||||
"longitude": -122.41755
|
||||
},
|
||||
"price": 200,
|
||||
"currency": "USD",
|
||||
"amenities": {
|
||||
"breakfast_provided": true,
|
||||
"internet": true,
|
||||
"washer": true,
|
||||
"dryer": false
|
||||
// 任何其他便利设施的详细信息。
|
||||
},
|
||||
"house_rules": "...",
|
||||
"contact_email": "..."
|
||||
// 任何其他详细信息。
|
||||
}
|
||||
```
|
||||
|
||||
### 进行预订
|
||||
|
||||
| 属性 | 值 |
|
||||
| ----------- | ------------------------- |
|
||||
| HTTP 方法 | `POST` |
|
||||
| 路径 | `/reserve` |
|
||||
| 描述 | 预订住宿。 |
|
||||
|
||||
#### 参数
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `accommodation_id` | number | 要预订的住宿 ID |
|
||||
| `dates` | mixed<sup>\*</sup> | 预订住宿的日期 |
|
||||
| `payment_details` | object | 包含付款方式字段(信用卡)的对象 |
|
||||
|
||||
<sup>\*</sup> `dates` 字段的格式应与搜索 API 中选定的日期范围格式一致。
|
||||
|
||||
#### 示例响应
|
||||
|
||||
成功预订后会返回一个预订对象。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 456, // 预订 ID。
|
||||
"total_price": 400,
|
||||
"currency": "USD",
|
||||
"dates": {
|
||||
"check_in": "2022-12-24",
|
||||
"check_out": "2022-12-27"
|
||||
},
|
||||
"accommodation": {
|
||||
"id": 561602,
|
||||
"address": {
|
||||
"country": "US",
|
||||
"street_address": "888 Brannan Street",
|
||||
"city": "San Francisco",
|
||||
"zip": "94103",
|
||||
"state": "CA",
|
||||
// ... 其他地址字段。
|
||||
},
|
||||
}
|
||||
"payment_details": {
|
||||
// 仅显示后 4 位数字。
|
||||
// 无论如何,我们不应该存储未加密的信用卡号。
|
||||
"card_last_four_digits": "1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
总而言之,旅行预订网站最重要的方面是:**搜索引擎优化**、**国际化**、**性能**和**设备支持**。
|
||||
|
||||
### 搜索引擎优化 (SEO)
|
||||
|
||||
#### 可添加书签的搜索结果
|
||||
|
||||
在这些旅行网站上搜索时,请注意搜索查询和过滤器将反映在 URL 中。 如果您在新窗口中加载 URL,则会显示相同的结果和搜索查询。 将搜索查询与 URL 同步是一项重要功能,因为:
|
||||
|
||||
1. **添加书签**:允许为特定搜索添加书签,这是用户在进行旅行研究时常见的操作。
|
||||
2. **深度链接**:其他网站(如旅行博客)链接到特定搜索过滤器的结果页面(例如,旧金山的度假租赁)。 这有助于提高网站的 SEO 和可发现性。
|
||||
|
||||
#### 为热门搜索预先生成页面
|
||||
|
||||
当您在搜索引擎中搜索“旧金山度假”时,您很可能会看到来自 Expedia、Airbnb、TripAdvisor 等热门旅行网站的结果。 这些页面不是内容文章;它们是网站的搜索结果页面,其中包含一些预先填充的过滤器。 这是如何实现的?
|
||||
|
||||
为了通过出现在与用户搜索相关的内容来改善 SEO,旅游网站会为热门搜索词生成大量页面,这些页面会显示这些词的相关列表。例如,Airbnb 为 ["旧金山度假租赁公寓"](https://www.airbnb.com/san-francisco-ca/stays) 和 ["纽约度假租赁公寓"](https://www.airbnb.com/new-york-ny/stays) 生成了专用页面。为了让搜索引擎了解这些专用页面,[Airbnb 有一个站点地图页面](https://www.airbnb.com/sitemaps/v2),专门用于列出其专用列表页面的链接。有成千上万个或更多的列表页面。
|
||||
|
||||
URL 通常通过以下两种方式之一实现:
|
||||
|
||||
* **可读 URL**:https://www.airbnb.com/san-francisco-ca/stays
|
||||
* **"丑陋" URL**:https://www.airbnb.com/search?location=123(其中 123 是映射到旧金山城市的内部 ID)
|
||||
|
||||
Airbnb 使用可读 URL,因为拥有可读 URL 有助于 SEO 排名,特别是如果 URL 包含搜索词本身。使用这种技术,搜索引擎会认为旅游网站有许多不同的内容页面,但实际上它们都呈现相同的结果页面,只是列表不同。这对于 SEO 来说非常棒。
|
||||
|
||||
#### 动态渲染 – 为抓取工具提供不同的页面
|
||||
|
||||
网站还可以利用 [动态渲染](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering)。动态渲染涉及使用 Web 服务器来区分抓取工具发出的请求和用户发出的请求。当抓取工具发出请求时,它会被路由到渲染器,该渲染器会生成针对抓取工具优化的内容版本,例如静态 HTML 版本。真实用户发出的请求会像往常一样处理。
|
||||
|
||||
动态渲染也可以用不同的方式完成。对于 [Expedia Group 的 Vrbo](https://medium.com/expedia-group-tech/improving-vrbo-homepage-loading-experience-e4b2207535f4),抓取工具会收到一个完整的页面,而真实用户只会收到一个轻量级页面和首屏内容,其余内容会在客户端异步加载。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
以下部分内容摘自我们关于 [国际化](/questions/quiz/designing-or-developing-for-multilingual-sites) 的 [测验问题](/questions/quiz):
|
||||
|
||||
* 拥有以支持的语言翻译的专用页面。
|
||||
* 使语言和国家/地区选择器非常突出(例如在导航栏中)。
|
||||
* 对于用户贡献的列表详细信息,添加自动翻译功能,以便访问国家/地区 X 但不会说国家/地区 X 语言的用户可以用他们的母语理解列表。
|
||||
* 在 `html` 标签上设置 `lang` 属性(例如 `<html lang="zh-cn">`),以告知浏览器和搜索引擎页面的语言,这有助于浏览器提供页面的翻译。这对于 SEO 也很重要。
|
||||
* 启用特定于区域设置的 UI:
|
||||
* 使用 [`:lang()` CSS 伪类](https://developer.mozilla.org/en-US/docs/Web/CSS/:lang) 来更改显示
|
||||
* 从右到左的语言
|
||||
* 使用 [CSS 逻辑属性](https://web.dev/learn/css/logical-properties/)
|
||||
* HTML 的 [`dir` 属性](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir)
|
||||
* CSS [`direction: rtl`](https://developer.mozilla.org/en-US/docs/Web/CSS/direction)
|
||||
* 考虑不同语言中文本长度的差异。
|
||||
* 不要连接翻译后的字符串。
|
||||
* 不要将文本放在图像中。
|
||||
* 注意不同文化中对颜色的感知。
|
||||
|
||||
### 性能
|
||||
|
||||
众所周知,性能会影响转化率,因此对于旨在让客户进行预订的网站,性能非常重要。性能优化已在前面的问题中进行了详细介绍,因此在本问题中,我们将列出相关的性能优化以及公司的博文,如果您想了解更多详细信息。
|
||||
|
||||
#### 图像优化
|
||||
|
||||
* **图片轮播**:照片广泛用于旅游网站,以展示目的地/住宿的吸引力。我们已经在 [图片轮播系统设计文章](/questions/system-design/image-carousel) 中介绍了图片轮播的实现。
|
||||
* **图片预加载/延迟加载**:一种非常有用的技术是使用 JavaScript 来精细控制图像的加载时间。
|
||||
* [Airbnb 通过延迟加载和预加载的组合优化了其房间列表中的图片轮播体验](https://medium.com/airbnb-engineering/building-a-faster-web-experience-with-the-posttask-scheduler-276b83454e91) 行为:
|
||||
1. 最初,仅加载第一张图片(其余图片将延迟加载)。
|
||||
2. 当用户显示查看更多图片的可能意图时,会预加载第二张图片:
|
||||
* 光标悬停在图片轮播上。
|
||||
* 通过制表符聚焦于“下一步”按钮。
|
||||
* 图片轮播进入视图(在移动设备上)。
|
||||
3. 如果用户确实查看了第二张图片(这表明有很高的浏览更多图片的意图),则会预加载接下来的三张图片(第 3 到第 5 张)。
|
||||
4. 当用户再次单击“下一步”以浏览更多图片时,会预加载 (n + 3)<sup>th</sup> 张图片。
|
||||
* 其他资源:[是的,我很懒 | TripAdvisor 工程和产品博客](https://www.tripadvisor.com/engineering/yes-im-lazy/)
|
||||
|
||||
<figure>
|
||||
<img alt="Airbnb Image Carousel Lazy Loading" className="mx-auto w-full max-w-5xl" loading="lazy" src="/img/questions/travel-booking-airbnb/airbnb-image-loading.gif" />
|
||||
|
||||
<figcaption>Airbnb image carousel lazy loading example on mobile</figcaption>
|
||||
</figure>
|
||||
|
||||
* **响应式图片**:为设备提供最合适的图片。
|
||||
* [Web 上的图片:第 1 部分 — 响应式图片 | Expedia Group Technology](https://medium.com/expedia-group-tech/images-on-the-web-part-1-responsive-images-5dc0066461bd)
|
||||
* [Web 上的图片:第 2 部分 — 实现响应式图片 | Expedia Group Technology](https://medium.com/expedia-group-tech/images-on-web-part-2-implementing-responsive-images-ca1d30f533f8)
|
||||
* **图片格式**:尽可能对照片使用 `webp` 格式,对图标使用 SVG 格式。
|
||||
* [使用 SVG 优化高密度显示器的图像精灵 | TripAdvisor 工程和产品博客](https://www.tripadvisor.com/engineering/optimizing-image-sprites-for-high-density-displays-with-svg/)
|
||||
|
||||
#### 代码拆分
|
||||
|
||||
* [Expedia 的 Vrbo 优先考虑首屏内容并首先加载代码](https://medium.com/expedia-group-tech/improving-vrbo-homepage-loading-experience-e4b2207535f4)。
|
||||
* 页面/路由级代码拆分,以防止在使用单页应用程序架构时出现巨大的 JavaScript 捆绑包。
|
||||
* 延迟加载未在初始渲染时显示的 UI 组件:(1)首屏以下的元素(例如页脚),(2)仅在交互后出现的元素(例如弹出窗口、模态框)。
|
||||
|
||||
#### 性能监控
|
||||
|
||||
* 使用 Lighthouse 和 Web Vitals 等工具来分析网站并衡量性能。
|
||||
* Airbnb 提出了自己的 [页面性能分数](https://medium.com/airbnb-engineering/creating-airbnbs-page-performance-score-5f664be0936) 并 [定义了对 Web 性能至关重要的指标](https://medium.com/airbnb-engineering/measuring-web-performance-at-airbnb-122da8d3ea3f)。
|
||||
* 设置在 CI 上运行的性能预算。
|
||||
|
||||
#### React 特定的技巧
|
||||
|
||||
* Airbnb 完成的 React 特定的性能优化:[Airbnb 列表页面上的 React 性能修复 | Airbnb 技术博客](https://medium.com/airbnb-engineering/recent-web-performance-fixes-on-airbnb-listing-pages-6cd8d93df6f4)
|
||||
* [12 个提高客户端页面性能的技巧 | Expedia Group Technology](https://medium.com/expedia-group-tech/12-tips-to-improve-client-side-page-performance-88c7bec27933)
|
||||
|
||||
#### 打包优化
|
||||
|
||||
* 模块联邦:[使用 Webpack 模块联邦创建 App Shell | Expedia Group Technology](https://medium.com/expedia-group-tech/using-webpack-module-federation-to-share-an-app-shell-7d23633510e)
|
||||
* [优化页面:资源提示、关键 CSS 和 Webpack | Expedia Group Technology](https://medium.com/expedia-group-tech/optimize-a-page-resource-hint-critical-css-webpack-c8cc7319fb87)
|
||||
|
||||
#### 虚拟列表/窗口化,用于具有无限滚动的长列表
|
||||
|
||||
对具有无限滚动的长列表使用虚拟列表/窗口化。阅读更多关于[patterns.dev 上的列表虚拟化](https://www.patterns.dev/posts/virtual-lists/)
|
||||
|
||||
#### 渐进式 Web 应用程序
|
||||
|
||||
* [使用 Service Workers 的渐进式 Web 应用程序 | Booking.com 工程](https://medium.com/booking-com-development/progressive-web-apps-with-service-workers-887e80abf9ef)
|
||||
|
||||
#### 其他性能技巧
|
||||
|
||||
* 当使用客户端渲染时,将列表响应拆分成多个有效负载,以便更快地显示结果。为了快速显示结果,响应有效负载可以拆分成几个块,例如返回前 5 个结果(或在首屏之上的任意数量),然后在页面上加载其余结果。
|
||||
* [Web 应用程序:分析客户端性能 | Expedia Group Technology](https://medium.com/expedia-group-tech/web-applications-analyzing-client-side-performance-37e9cc4ad86b)
|
||||
* [快速或回家:优化客户端性能的过程](https://medium.com/expedia-group-tech/go-fast-or-go-home-the-process-of-optimizing-for-client-performance-57bb497402e)
|
||||
|
||||
#### 延伸阅读
|
||||
|
||||
* [在为多语言网站设计或开发时,您必须注意哪些事项?](/questions/quiz/designing-or-developing-for-multilingual-sites)
|
||||
* [您如何使用多种语言提供页面内容?](/questions/quiz/how-do-you-serve-a-page-with-content-in-multiple-languages)
|
||||
* [构建 Airbnb 的国际化平台](https://medium.com/airbnb-engineering/building-airbnbs-internationalization-platform-45cf0104b63c)
|
||||
* [在 Airbnb 上添加对阿拉伯语和希伯来语的支持](https://medium.com/airbnb-engineering/adding-support-for-arabic-and-hebrew-languages-on-airbnb-355f35a4e6b7)
|
||||
|
||||
### 设备支持
|
||||
|
||||
* **响应式图片**:为设备提供最合适的图片,如上文图片优化部分所述。
|
||||
* [Web 上的图片:第 1 部分 — 响应式图片 | Expedia Group Technology](https://medium.com/expedia-group-tech/images-on-the-web-part-1-responsive-images-5dc0066461bd)
|
||||
* [Web 上的图片:第 2 部分 — 实现响应式图片 | Expedia Group Technology](https://medium.com/expedia-group-tech/images-on-web-part-2-implementing-responsive-images-ca1d30f533f8)
|
||||
* **特定于设备的 UI**:为不同的设备显示不同的 UI,这可能涉及使用完全不同的信息架构。
|
||||
* 根据设备宽度和高度,一行和每页的结果项的动态数量。
|
||||
* 由于地图在较小的设备上由于屏幕空间较小而难以使用,请考虑根本不使用地图 UI。如果没有地图,客户端可以完全避免在移动设备上加载地图代码和纹理。
|
||||
* Airbnb 的列表在移动设备上更像应用程序,带有浮动底部栏。列表页面也不会在新页面上打开列表,大概是因为在移动设备上管理多个打开的标签页更难。
|
||||
* 支持在移动设备上滑动图片轮播。
|
||||
* 在移动设备上采用不太激进的预加载策略,因为移动数据可能更贵。
|
||||
* 交互元素在移动设备上应该更大。
|
||||
|
||||
### 用户体验
|
||||
|
||||
#### 保留搜索上下文
|
||||
|
||||
对于大多数旅行网站,点击列表将在新标签页中打开列表详细信息。这样做的原因是,用户在缩小搜索结果范围后倾向于浏览多个列表,并且在新页面中打开列表详细信息允许用户轻松深入了解列表的详细信息,如果列表不符合他们的期望或他们想查看其他列表,则返回到他们离开的地方。
|
||||
|
||||
另一种方法是在同一页面上的全屏模态框中打开列表详细信息,并浅层更新 URL(通过 [`History.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) 类似于 [Zillow.com](https://www.zillow.com) 和 [Instagram.com](https://www.instagram.com)。关闭模态框将显示后面的搜索结果,用户可以继续浏览结果。这种方法的缺点是它更难实现,并且需要在客户端使用更多 JavaScript,因为所有内容都在一个页面上,并且需要通过 AJAX 获取列表详细信息。
|
||||
|
||||
### 可访问性
|
||||
|
||||
* 图片 `alt` 标签:如果用户上传的图片没有提供描述,则可以使用空的 `alt` 标签。
|
||||
* [Expedia 可访问性指南](https://accessibility.expedia.biz/pages/exagindex) 涵盖了颜色、控件、键盘和输入模式、表单、图像、内容结构、阅读顺序和导航顺序等主题。
|
||||
|
||||
### 表单优化
|
||||
|
||||
表单优化已在[电子商务网站系统设计](/questions/system-design/e-commerce-amazon)中详细介绍。 总结一下:
|
||||
|
||||
* 特定于国家/地区的地址/付款表单。
|
||||
* 优化自动填充体验。
|
||||
* 显示错误消息。
|
||||
* 清除焦点状态。
|
||||
* 在 web.dev 上阅读更多关于构建良好的[付款表单](https://web.dev/learn/forms/payment/)和[一般表单最佳实践](https://web.dev/payment-and-address-form-best-practices/)的信息。
|
||||
|
||||
### 其他主题
|
||||
|
||||
* 地图
|
||||
* 接近的标记可以[聚集在一起](https://developers.google.com/maps/documentation/javascript/marker-clustering)
|
||||
* 白标
|
||||
* [使用一个代码库构建和扩展不同的旅游网站 | Agoda Engineering & Design](https://medium.com/agoda-engineering/building-and-scaling-different-travel-websites-with-one-codebase-fc6f0202c2e1)
|
||||
* [管理和扩展不同的白标开发和测试环境 | Agoda Engineering & Design](https://medium.com/agoda-engineering/managing-and-scaling-different-white-label-development-and-testing-environments-4e90748fcb3b)
|
||||
* 测试
|
||||
* [降低噪音底线 | TripAdvisor 工程和产品博客](https://www.tripadvisor.com/engineering/lowering-the-noise-floor/)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "106244ec",
|
||||
"excerpt": "80437ad6"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"6f97f7ff",
|
||||
"6188f3b0",
|
||||
"9ec6d64c",
|
||||
"9f28ebb4"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: 视频会议(例如 Zoom)
|
||||
excerpt: 设计一个类似 Zoom 和 Google Meet 的视频会议应用程序
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
TODO
|
||||
|
||||
### 真实案例
|
||||
|
||||
* TODO
|
||||
* TODO
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"6188f3b0"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"3cce7975",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"b9f3fd8c",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"fec93fe5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"84fb1fa5",
|
||||
"6188f3b0",
|
||||
"2a7816d0",
|
||||
"62fd75b6",
|
||||
"6188f3b0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
## 需求探索
|
||||
|
||||
待办事项
|
||||
|
||||
***
|
||||
|
||||
## 架构/高层设计
|
||||
|
||||
待办事项
|
||||
|
||||
***
|
||||
|
||||
## 数据模型
|
||||
|
||||
待办事项
|
||||
|
||||
***
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
待办事项
|
||||
|
||||
***
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
待办事项
|
||||
|
||||
***
|
||||
|
||||
## 参考
|
||||
|
||||
待办事项
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"frontmatter": {
|
||||
"title": "1ff509f0",
|
||||
"excerpt": "7a9ee270"
|
||||
},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"a9e0ce4d",
|
||||
"6f97f7ff",
|
||||
"9dccef52",
|
||||
"90a46056",
|
||||
"3a57baa5",
|
||||
"9ec6d64c",
|
||||
"454e0fde"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"a9e0ce4d",
|
||||
"6f97f7ff",
|
||||
"9dccef52",
|
||||
"90a46056",
|
||||
"3a57baa5",
|
||||
"9ec6d64c",
|
||||
"454e0fde"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: 视频流(例如 Netflix)
|
||||
excerpt: 设计一个类似 Netflix 和 YouTube 的视频流应用程序
|
||||
---
|
||||
|
||||
设计视频流应用程序是一个常见但复杂的系统设计问题,但提供关于如何设计此类平台前端的综合指南的资源有限。
|
||||
|
||||
## 问题
|
||||
|
||||
设计一个类似于 Netflix 和 YouTube 等平台的视频流应用程序,允许用户浏览视频内容库以发现有趣的视频并观看视频。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 真实案例
|
||||
|
||||
* https://www.netflix.com
|
||||
* https://www.youtube.com
|
||||
* https://www.hulu.com
|
||||
* https://www.primevideo.com
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
{
|
||||
"frontmatter": {},
|
||||
"content": {
|
||||
"source": {
|
||||
"locale": "en-US",
|
||||
"hashes": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"592401c0",
|
||||
"e322b3d9",
|
||||
"d45906f5",
|
||||
"f324a73",
|
||||
"33888023",
|
||||
"cc56802d",
|
||||
"9500c208",
|
||||
"b7e8295b",
|
||||
"c4e1335d",
|
||||
"a57d304e",
|
||||
"402fa11e",
|
||||
"a1af69bc",
|
||||
"1eff44e7",
|
||||
"e8a6deed",
|
||||
"64f73f8a",
|
||||
"2cb8eeee",
|
||||
"e04a43e5",
|
||||
"5fafac50",
|
||||
"78b464c7",
|
||||
"58daeda3",
|
||||
"c7c6af41",
|
||||
"f6ddbc8d",
|
||||
"a7700876",
|
||||
"3cce7975",
|
||||
"bc3ae319",
|
||||
"98e0d8a3",
|
||||
"5b92aec7",
|
||||
"409f6ac2",
|
||||
"587ddfa2",
|
||||
"dd888cf4",
|
||||
"58295773",
|
||||
"a9b8f4a7",
|
||||
"f514e50b",
|
||||
"d135872a",
|
||||
"58473348",
|
||||
"baec392f",
|
||||
"2ad7b3c0",
|
||||
"ee6ab375",
|
||||
"be4fbf9f",
|
||||
"dfe35978",
|
||||
"5df0cd3a",
|
||||
"d66b73a9",
|
||||
"4b2d1f52",
|
||||
"91d66590",
|
||||
"c478a8c2",
|
||||
"ae94be71",
|
||||
"ae4f7d94",
|
||||
"b9f3fd8c",
|
||||
"21ab0819",
|
||||
"919d8748",
|
||||
"e08dc879",
|
||||
"fec93fe5",
|
||||
"893a5145",
|
||||
"a8592274",
|
||||
"67ebc763",
|
||||
"cb94620",
|
||||
"819b8a15",
|
||||
"aed00bde",
|
||||
"ebb7265e",
|
||||
"1811f539",
|
||||
"b7fdaeb0",
|
||||
"dda34901",
|
||||
"4780c506",
|
||||
"cae18399",
|
||||
"67aecaf",
|
||||
"cf1cb122",
|
||||
"d41dbeee",
|
||||
"fbbed032",
|
||||
"c4828922",
|
||||
"861516e",
|
||||
"6417714a",
|
||||
"66e64733",
|
||||
"1a385f44",
|
||||
"45a3f202",
|
||||
"6fe98e93",
|
||||
"48b4897d",
|
||||
"c2e3f8c6",
|
||||
"e8ce1bfa",
|
||||
"84fb1fa5",
|
||||
"4eaf730",
|
||||
"c2439c51",
|
||||
"2f111712",
|
||||
"5b4c8630",
|
||||
"59deb3cd",
|
||||
"a726aa62",
|
||||
"94e68918",
|
||||
"272caba4",
|
||||
"250a4a1e",
|
||||
"8603c96f",
|
||||
"d6280915",
|
||||
"a42789d7",
|
||||
"6ea25573",
|
||||
"a17f32cd",
|
||||
"7492415f",
|
||||
"899e6023",
|
||||
"c919370e",
|
||||
"28b9adca",
|
||||
"8d93d310",
|
||||
"47717dd8",
|
||||
"d18ad18",
|
||||
"1ba545f9",
|
||||
"7d4456e9",
|
||||
"1aa3b7e9",
|
||||
"7c457b79",
|
||||
"fee7cfe3",
|
||||
"f155cdb2",
|
||||
"5ff2194f",
|
||||
"7c0c6a87",
|
||||
"3aae7036",
|
||||
"55e2491a",
|
||||
"6c5cfed0",
|
||||
"c73e2a64",
|
||||
"85c5a8fd",
|
||||
"65b02fbb",
|
||||
"99a7d4bb",
|
||||
"659e33a1",
|
||||
"37a14548",
|
||||
"5431b010",
|
||||
"495fa03c",
|
||||
"a04f3ad4",
|
||||
"81fa1fc4",
|
||||
"d358a2b",
|
||||
"6946fd48",
|
||||
"211779ff",
|
||||
"a76c4c8e",
|
||||
"258e5a80",
|
||||
"5fae3862",
|
||||
"d5a8f37c",
|
||||
"62ddf9fa",
|
||||
"ec646631",
|
||||
"66184708",
|
||||
"b06ed09d",
|
||||
"ef5663cd",
|
||||
"fdc8f01c",
|
||||
"f3235daf",
|
||||
"c7882714",
|
||||
"7d272e55",
|
||||
"10d15c51",
|
||||
"5bf53b1d",
|
||||
"aacc4342",
|
||||
"2bd80969",
|
||||
"682069fa",
|
||||
"b49ed4cf",
|
||||
"2a040585",
|
||||
"2c33c0c8",
|
||||
"937c5dda",
|
||||
"71c25c53",
|
||||
"94e222d8",
|
||||
"17dbd9ea",
|
||||
"78dfbbe3",
|
||||
"305afa7b",
|
||||
"f0cc2aa2",
|
||||
"f22133d2",
|
||||
"b60a9254",
|
||||
"5d978604",
|
||||
"b149c5ba",
|
||||
"6bea7d00",
|
||||
"75eb660d",
|
||||
"23fc70dd",
|
||||
"cee05203",
|
||||
"4f3437f9",
|
||||
"8acfc34b",
|
||||
"19288e44",
|
||||
"bb3cedbe",
|
||||
"d23633f2",
|
||||
"67c0242c",
|
||||
"6a831af1",
|
||||
"ad492f5c",
|
||||
"f8f07c",
|
||||
"d9dcb7f",
|
||||
"d41afc69",
|
||||
"f7dd0d66",
|
||||
"5fe35c42",
|
||||
"b6916e9e",
|
||||
"c69543c4",
|
||||
"19dae11e",
|
||||
"cd2718bc",
|
||||
"d2208760",
|
||||
"34a27822",
|
||||
"ce12980f",
|
||||
"23fc70dd",
|
||||
"306e7c74",
|
||||
"9846f084",
|
||||
"7f19f705",
|
||||
"9e1e4af1",
|
||||
"2b95dd37",
|
||||
"26e52182",
|
||||
"588b876a",
|
||||
"2370dcb0",
|
||||
"7ac47b30",
|
||||
"d7bb85e3",
|
||||
"f63694bb",
|
||||
"80b51ddd",
|
||||
"53b01672",
|
||||
"21e93517",
|
||||
"97f7b0f4",
|
||||
"7c8c0ad3",
|
||||
"acbe87bf",
|
||||
"502ffbc5",
|
||||
"ab34bd00",
|
||||
"94fc1ab0",
|
||||
"a7d886b5",
|
||||
"167ce8ff",
|
||||
"72815412",
|
||||
"f68237b0",
|
||||
"a3c00fdd",
|
||||
"7ce1e1e2",
|
||||
"e7bb7378",
|
||||
"601745fd",
|
||||
"69d6965d",
|
||||
"411bdb8a",
|
||||
"1ad403f7",
|
||||
"52f4fac6",
|
||||
"e3946902",
|
||||
"97be2d12",
|
||||
"5ebbb865",
|
||||
"54a0c606",
|
||||
"6afeeca9",
|
||||
"814b866c",
|
||||
"dd5e9cc2",
|
||||
"eed44c89",
|
||||
"a7f37b82",
|
||||
"df29b84a",
|
||||
"7f29bf22",
|
||||
"9ca503ce",
|
||||
"78b2161a",
|
||||
"47ad4f0",
|
||||
"65955e56",
|
||||
"62fd75b6",
|
||||
"829a45d1"
|
||||
]
|
||||
},
|
||||
"targets": {
|
||||
"zh-CN": [
|
||||
"b5e6c41e",
|
||||
"6cedbfa3",
|
||||
"592401c0",
|
||||
"e322b3d9",
|
||||
"d45906f5",
|
||||
"f324a73",
|
||||
"33888023",
|
||||
"cc56802d",
|
||||
"9500c208",
|
||||
"b7e8295b",
|
||||
"c4e1335d",
|
||||
"a57d304e",
|
||||
"402fa11e",
|
||||
"a1af69bc",
|
||||
"1eff44e7",
|
||||
"e8a6deed",
|
||||
"64f73f8a",
|
||||
"2cb8eeee",
|
||||
"e04a43e5",
|
||||
"5fafac50",
|
||||
"78b464c7",
|
||||
"58daeda3",
|
||||
"c7c6af41",
|
||||
"f6ddbc8d",
|
||||
"a7700876",
|
||||
"3cce7975",
|
||||
"bc3ae319",
|
||||
"98e0d8a3",
|
||||
"5b92aec7",
|
||||
"409f6ac2",
|
||||
"587ddfa2",
|
||||
"dd888cf4",
|
||||
"58295773",
|
||||
"a9b8f4a7",
|
||||
"f514e50b",
|
||||
"d135872a",
|
||||
"58473348",
|
||||
"baec392f",
|
||||
"2ad7b3c0",
|
||||
"ee6ab375",
|
||||
"be4fbf9f",
|
||||
"dfe35978",
|
||||
"5df0cd3a",
|
||||
"d66b73a9",
|
||||
"4b2d1f52",
|
||||
"91d66590",
|
||||
"c478a8c2",
|
||||
"ae94be71",
|
||||
"ae4f7d94",
|
||||
"b9f3fd8c",
|
||||
"21ab0819",
|
||||
"919d8748",
|
||||
"e08dc879",
|
||||
"fec93fe5",
|
||||
"893a5145",
|
||||
"a8592274",
|
||||
"67ebc763",
|
||||
"cb94620",
|
||||
"819b8a15",
|
||||
"aed00bde",
|
||||
"ebb7265e",
|
||||
"1811f539",
|
||||
"b7fdaeb0",
|
||||
"dda34901",
|
||||
"4780c506",
|
||||
"cae18399",
|
||||
"67aecaf",
|
||||
"cf1cb122",
|
||||
"d41dbeee",
|
||||
"fbbed032",
|
||||
"c4828922",
|
||||
"861516e",
|
||||
"6417714a",
|
||||
"66e64733",
|
||||
"1a385f44",
|
||||
"45a3f202",
|
||||
"6fe98e93",
|
||||
"48b4897d",
|
||||
"c2e3f8c6",
|
||||
"e8ce1bfa",
|
||||
"84fb1fa5",
|
||||
"4eaf730",
|
||||
"c2439c51",
|
||||
"2f111712",
|
||||
"5b4c8630",
|
||||
"59deb3cd",
|
||||
"a726aa62",
|
||||
"94e68918",
|
||||
"272caba4",
|
||||
"250a4a1e",
|
||||
"8603c96f",
|
||||
"d6280915",
|
||||
"a42789d7",
|
||||
"6ea25573",
|
||||
"a17f32cd",
|
||||
"7492415f",
|
||||
"899e6023",
|
||||
"c919370e",
|
||||
"28b9adca",
|
||||
"8d93d310",
|
||||
"47717dd8",
|
||||
"d18ad18",
|
||||
"1ba545f9",
|
||||
"7d4456e9",
|
||||
"1aa3b7e9",
|
||||
"7c457b79",
|
||||
"fee7cfe3",
|
||||
"f155cdb2",
|
||||
"5ff2194f",
|
||||
"7c0c6a87",
|
||||
"3aae7036",
|
||||
"55e2491a",
|
||||
"6c5cfed0",
|
||||
"c73e2a64",
|
||||
"85c5a8fd",
|
||||
"65b02fbb",
|
||||
"99a7d4bb",
|
||||
"659e33a1",
|
||||
"37a14548",
|
||||
"5431b010",
|
||||
"495fa03c",
|
||||
"a04f3ad4",
|
||||
"81fa1fc4",
|
||||
"d358a2b",
|
||||
"6946fd48",
|
||||
"211779ff",
|
||||
"a76c4c8e",
|
||||
"258e5a80",
|
||||
"5fae3862",
|
||||
"d5a8f37c",
|
||||
"62ddf9fa",
|
||||
"ec646631",
|
||||
"66184708",
|
||||
"b06ed09d",
|
||||
"ef5663cd",
|
||||
"fdc8f01c",
|
||||
"f3235daf",
|
||||
"c7882714",
|
||||
"7d272e55",
|
||||
"10d15c51",
|
||||
"5bf53b1d",
|
||||
"aacc4342",
|
||||
"2bd80969",
|
||||
"682069fa",
|
||||
"b49ed4cf",
|
||||
"2a040585",
|
||||
"2c33c0c8",
|
||||
"937c5dda",
|
||||
"71c25c53",
|
||||
"94e222d8",
|
||||
"17dbd9ea",
|
||||
"78dfbbe3",
|
||||
"305afa7b",
|
||||
"f0cc2aa2",
|
||||
"f22133d2",
|
||||
"b60a9254",
|
||||
"5d978604",
|
||||
"b149c5ba",
|
||||
"6bea7d00",
|
||||
"75eb660d",
|
||||
"23fc70dd",
|
||||
"cee05203",
|
||||
"4f3437f9",
|
||||
"8acfc34b",
|
||||
"19288e44",
|
||||
"bb3cedbe",
|
||||
"d23633f2",
|
||||
"67c0242c",
|
||||
"6a831af1",
|
||||
"ad492f5c",
|
||||
"f8f07c",
|
||||
"d9dcb7f",
|
||||
"d41afc69",
|
||||
"f7dd0d66",
|
||||
"5fe35c42",
|
||||
"b6916e9e",
|
||||
"c69543c4",
|
||||
"19dae11e",
|
||||
"cd2718bc",
|
||||
"d2208760",
|
||||
"34a27822",
|
||||
"ce12980f",
|
||||
"23fc70dd",
|
||||
"306e7c74",
|
||||
"9846f084",
|
||||
"7f19f705",
|
||||
"9e1e4af1",
|
||||
"2b95dd37",
|
||||
"26e52182",
|
||||
"588b876a",
|
||||
"2370dcb0",
|
||||
"7ac47b30",
|
||||
"d7bb85e3",
|
||||
"f63694bb",
|
||||
"80b51ddd",
|
||||
"53b01672",
|
||||
"21e93517",
|
||||
"97f7b0f4",
|
||||
"7c8c0ad3",
|
||||
"acbe87bf",
|
||||
"502ffbc5",
|
||||
"ab34bd00",
|
||||
"94fc1ab0",
|
||||
"a7d886b5",
|
||||
"167ce8ff",
|
||||
"72815412",
|
||||
"f68237b0",
|
||||
"a3c00fdd",
|
||||
"7ce1e1e2",
|
||||
"e7bb7378",
|
||||
"601745fd",
|
||||
"69d6965d",
|
||||
"411bdb8a",
|
||||
"1ad403f7",
|
||||
"52f4fac6",
|
||||
"e3946902",
|
||||
"97be2d12",
|
||||
"5ebbb865",
|
||||
"54a0c606",
|
||||
"6afeeca9",
|
||||
"814b866c",
|
||||
"dd5e9cc2",
|
||||
"eed44c89",
|
||||
"a7f37b82",
|
||||
"df29b84a",
|
||||
"7f29bf22",
|
||||
"9ca503ce",
|
||||
"78b2161a",
|
||||
"47ad4f0",
|
||||
"65955e56",
|
||||
"62fd75b6",
|
||||
"829a45d1"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,771 @@
|
|||
## 需求探索
|
||||
|
||||
### 需要支持哪些核心功能?
|
||||
|
||||
* 在主页上浏览推荐视频(发现/推荐页面)。
|
||||
* 在发现/推荐页面的顶部自动播放宣传视频。
|
||||
* 在独立页面上播放视频内容。
|
||||
|
||||
### 期望的视频播放质量和分辨率是什么?
|
||||
|
||||
应支持多种分辨率和流媒体质量选项,并根据设备情况自动选择。
|
||||
|
||||
### 视频播放器应包含哪些功能?
|
||||
|
||||
* **视频进度**: 播放、暂停、跳过、跳转到视频的特定时间戳、调整播放速率。
|
||||
* **音频**: 更改语言、调整音量。
|
||||
* **字幕**: 字幕显示和字幕语言选择。
|
||||
|
||||
### 应用程序将在哪些设备上使用?
|
||||
|
||||
主要用于桌面,但也应可在平板电脑和移动设备上使用。
|
||||
|
||||
### 非功能性需求是什么?
|
||||
|
||||
优先考虑流畅的视频观看体验,用户不应等待太久才能开始观看视频:
|
||||
|
||||
* 即使要提供较低质量的版本,网速较慢的用户也应该能够观看视频。
|
||||
* 减少卡顿和缓冲。
|
||||
* 快速的 [视频启动时间](https://www.mux.com/blog/the-video-startup-time-metric-explained)。
|
||||
|
||||
## 背景
|
||||
|
||||
由于在网络上播放媒体是一个相当专业的领域,大多数人可能没有太多经验,因此我们提供了关于系统设计面试中需要了解的关于视频播放的重要技术细节的摘要。 事实上,这里涵盖的大部分内容超出了对候选人的期望,但了解更多并没有坏处。
|
||||
|
||||
### 术语表
|
||||
|
||||
* **流媒体**:通过互联网以连续和实时的方式传输多媒体内容(如视频和音频)的过程。 它允许用户在传输内容的同时观看或收听内容,而无需在播放开始之前下载整个文件。
|
||||
* **缓冲**:预加载视频内容以确保流畅播放的过程,防止因网络连接速度慢而中断。
|
||||
* **比特率**:指视频流中每秒传输的数据量。 它决定了视频文件的质量和大小,比特率越高,质量越好,但文件大小也越大。
|
||||
* **帧率**:每秒显示的视频帧数,通常以每秒帧数 (fps) 为单位衡量。 常见的帧率包括电影的 24fps 以及电视和在线视频的 30fps 或 60fps。
|
||||
* **分辨率**:以宽度和高度指定视频的尺寸(例如,全高清的 1920 x 1080 像素)。 较高的分辨率提供更好的视觉质量,但需要更多的带宽。
|
||||
* **编解码器**:编码和解码视频和音频数据的软件或硬件组件。 常见的视频编解码器包括 H.264、H.265 (HEVC)、VP9 和 AV1。
|
||||
* **带宽**:在给定的时间范围内可以通过网络连接传输的数据量。 在视频流中,需要足够的带宽才能流畅地以所需质量传输视频内容。 具有较高比特率的较高质量视频需要更多带宽才能不间断地播放。
|
||||
* **海报**:视频的静态缩略图图像。
|
||||
* **隐藏式字幕 (CC)**:在视频播放期间显示的基于文本的字幕,通常用于提供可访问性和语言翻译。 它们与字幕不同,但在面试中可以被同等对待。
|
||||
* **播放控件**:用于视频控制的用户界面元素,包括播放、暂停、快进和音量。
|
||||
* **Seeking**:视频播放中的 Seeking 是指在不从头开始播放的情况下移动到视频中的特定点或时间的操作。 用户可以跳转到视频中的特定场景或时间码,通常通过与进度条或时间线交互来实现。
|
||||
* **Scrubbing**:Scrubbing 是一种用户操作,涉及拖动视频播放器的播放头或进度条以浏览视频内容。 它允许用户在视频中快速向前或向后移动以查找特定场景或时刻。
|
||||
|
||||
我们将在下面的内容中经常使用这些术语。
|
||||
|
||||
### 网络上的视频播放
|
||||
|
||||
在网站上播放视频的最基本方法是在页面上使用`<video>` HTML 标签,其 `src` 属性指向 `mp4` 或 `webm` 文件,就像 `<img>` 标签一样。然而,这种在网页上播放视频的最基本方法并不能提供最佳的用户体验,因为它不支持自适应比特率。
|
||||
|
||||
Netflix 和 YouTube 上的复杂视频播放器利用以下关键组件:
|
||||
|
||||
1. **播放器界面**:这包括视频播放器的用户界面,它提供播放、暂停和音量调节等控件。浏览器提供基本的播放控制 UI,但通常您希望更好地控制样式和外观。
|
||||
2. **流媒体协议**:流媒体涉及逐步下载已分割成较小片段的大型视频文件。播放器按顺序下载和播放这些片段,维护一个缓冲区以处理网络波动。常见的流媒体协议是 HTTP Live Streaming (HLS) 和 Dynamic Adaptive Streaming over HTTP (DASH)。
|
||||
3. **清单文件**:清单文件引导视频播放器找到视频片段文件的位置。它们包括一个主清单,这是第一个接触点,它将播放器定向到视频的各种呈现,以及每个特定视频质量的呈现清单。清单文件的格式因所使用的流媒体协议而异。
|
||||
4. **自适应比特率流**:此技术允许播放器从不同版本的视频(各种分辨率和比特率)中选择,以确保基于用户互联网速度的流畅播放。为了避免缓冲,视频播放器动态调整播放质量,并使用清单文件来确定所需质量的片段文件的位置。
|
||||
|
||||
### 视频格式
|
||||
|
||||
[WebM](https://www.webmproject.org/) 和 MP4 是常见的视频格式,它们之间的区别主要与它们的视频编码、浏览器支持、许可和使用有关:
|
||||
|
||||
| 区域 | WebM | MP4 |
|
||||
| --- | --- | --- |
|
||||
| 用途 | 在线流媒体。 | 视频存储、视频编辑、广播和流媒体。 |
|
||||
| 浏览器支持 | 在 Firefox、Chrome 和 Opera 上受支持。在移动设备和非 Web 平台上支持有限。 | 更多通用支持。 |
|
||||
| 编码和压缩 | 使用 VP8 或 VP9 视频编解码器和 Vorbis 或 Opus 进行音频。 VP8/VP9 编解码器以其高效的压缩而闻名,使其适用于带宽使用较少的在线流媒体。 | 使用 H.264 (或 AVC) 视频编解码器和 AAC 进行音频。 H.264 因其高压缩效率和出色的视频质量而广受好评,即使在较低的比特率下也是如此。 |
|
||||
|
||||
与 WebP 类似,WebM 也是由 Google 开发的,是一种用于网络媒体的高性能文件格式。 WebM 更适合基于 Web 的应用程序,重点是开源和高效的流媒体,而 MP4 是一种通用格式,具有广泛的设备和平台支持,使其成为各种视频应用程序的热门选择。
|
||||
|
||||
## 架构/高级设计
|
||||
|
||||
### 渲染方法
|
||||
|
||||
视频流应用程序具有以下特征:
|
||||
|
||||
* 视频标题可通过搜索引擎搜索。 YouTube 视频大多是公开的,而 Netflix 有一个标题页面,其中仅包含视频详细信息和一些艺术/缩略图([Netflix 标题页面示例](https://www.netflix.com/title/80057281))。
|
||||
* 某些视频观看页面仅供已登录的高级用户访问(在 Netflix 的情况下)。
|
||||
* 由于浏览视频推荐和视频播放交互,页面交互量很大。
|
||||
* 需要快速的初始加载速度和视频启动速度。
|
||||
|
||||
对于仅供已登录用户访问的页面,服务器端渲染 (SSR) 将略微提高性能,但 SSR 并不关键。对于可通过搜索引擎发现的页面(公共视频),SEO 以及 SSR 将很重要。
|
||||
|
||||
YouTube SSRs 其浏览/发现页面的基本框架。
|
||||
|
||||

|
||||
|
||||
Netflix 对整个 [视频标题页面](https://www.netflix.com/title/80057281) 进行 SSR。
|
||||
|
||||

|
||||
|
||||
对于观看页面,尤其是在 Netflix 的情况下,视频占据了整个页面,SSR 并不那么有用。 SSR-ed HTML 不包括已加载的视频或开始播放所需的任何缓冲数据,并且无论如何都需要 JavaScript 进行视频流播放。
|
||||
|
||||

|
||||
|
||||
YouTube 对视频的静态预览(海报图像)进行 SSR,而 Netflix 不对任何可见内容进行 SSR。
|
||||
|
||||

|
||||
|
||||
然而,Netflix 的初始 HTML 包含 React 应用程序启动页面上视频播放器所需的数据。如果此数据未出现在初始 HTML 中,则页面需要发出请求来获取数据,这需要额外的往返,并且会更慢。
|
||||
|
||||

|
||||
|
||||
| 服务 | 页面 | 访问权限 | 渲染 |
|
||||
| --- | --- | --- | --- |
|
||||
| Netflix | 视频标题页面 | 公开 | SSR 全页面 |
|
||||
| Netflix | 浏览/发现页面 | 已登录 | SSR 首屏 |
|
||||
| Netflix | 观看页面 | 已登录 | SSR 仅应用程序数据 (JSON) |
|
||||
| YouTube | 主页(推荐) | 公开 | SSR 骨架 |
|
||||
| YouTube | 观看页面 | 公开 | SSR UI 骨架,带视频预览/海报图像 |
|
||||
|
||||
从表中可以看出,这里没有明确的规则。YouTube 仅对其页面进行 SSR 骨架。Netflix 对公共页面和浏览页面进行 SSR,因为它提高了用户参与度。SSR 可用于受益于高用户参与度的视频列表页面。对于视频观看页面,SSR 并不那么重要,可以使用 CSR。
|
||||
|
||||
### 单页应用程序 (SPA) 还是多页应用程序 (MPA)?
|
||||
|
||||
由于视频流网站具有高度交互性,并且发现页面和观看页面之间的导航非常常见(尤其是在用户仍在选择视频时),因此在页面导航中保留在发现页面上获取的数据将提高性能。此外,在 SPA 上,客户端可以预取后续观看页面所需的数据,从而缩短视频启动时间。
|
||||
|
||||
Netflix 和 YouTube 都是单页应用程序。
|
||||
|
||||
### 组件职责
|
||||
|
||||

|
||||
|
||||
* **服务器**:提供 HTTP API 以获取视频推荐和视频对象元数据。
|
||||
* **视频 CDN 服务器**:CDN 服务器本身用于获取视频内容。允许单独获取视频片段。
|
||||
* **客户端存储**:存储整个应用程序所需的数据。存储中的大多数数据将是视频推荐页面所需的服务器生成的数据。保留跨导航的数据,以便在用户返回“发现页面”浏览更多推荐时,无需再次获取推荐列表。
|
||||
* **发现页面**:用户用于浏览推荐视频的页面。
|
||||
* **广告牌视频播放器**:位于顶部的特色视频,在加载页面时立即播放。
|
||||
* **视频列表**:视频类别列表。每个类别显示一个水平的视频缩略图列表。
|
||||
* **观看页面**:用户观看完整视频的页面。
|
||||
* **全屏视频播放器**:播放视频并包含视频播放控件。
|
||||
|
||||
每个页面上的视频播放器组件(紫色框)将向视频 CDN 服务器发出请求,以流式传输方式获取视频片段。
|
||||
|
||||
## 数据模型
|
||||
|
||||
| 实体 | 来源 | 属于 | 字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Recommendations` | 服务器 | 发现页面 | `lists`(`VideoList` 列表),`pagination`(分页元数据) |
|
||||
| `VideoList` | 服务器 | 发现页面 | `videos`(`VideoMetadata` 列表),`pagination`(分页元数据) |
|
||||
| `VideoMetadata` | 服务器 | 发现页面 | `id`、`title`、`boxart_url` 等 |
|
||||
|
||||
视频推荐应存储在客户端存储中,该存储在页面导航中保留。这充当缓存,以便在用户返回“发现页面”浏览更多推荐时(无需网络请求)立即呈现视频推荐。
|
||||
|
||||
视频播放器包含独立的 [数据模型和 API](#video-player-data-model-architecture-and-api),这将在[下面的一个专门的部分](#video-player-data-model-architecture-and-api)中介绍。
|
||||
|
||||
## 接口定义 (API)
|
||||
|
||||
### 视频推荐 API
|
||||
|
||||
此 API 用于浏览/发现页面,以呈现视频类别列表和每个类别中的热门视频。可以使用基于游标的分页来获取更多推荐类别和更多类别视频。
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendations": {
|
||||
"items": [
|
||||
{
|
||||
"name": "TV Shows",
|
||||
"videos": {
|
||||
"items": [
|
||||
{ "videoId": 123, "title": "...", "boxArtUrl": "..." },
|
||||
{ "videoId": 124, "title": "...", "boxArtUrl": "..." }
|
||||
],
|
||||
"pagination": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "New Releases",
|
||||
"videos": {
|
||||
"items": [
|
||||
{ "videoId": 125, "title": "...", "boxArtUrl": "..." },
|
||||
{ "videoId": 126, "title": "...", "boxArtUrl": "..." }
|
||||
],
|
||||
"pagination": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以使用基于偏移的分页和基于游标的分页。
|
||||
|
||||
推荐和视频的第一页数据:
|
||||
|
||||
1. 用于 SSR 初始 HTML(参考渲染方法部分中的图像)。
|
||||
2. 在`<script>`标签中呈现为 JSON 数据,并注入到客户端存储(`window.netflix.reactContext`)中。
|
||||
|
||||
后续页面的数据从 HTTP API 获取,并添加到客户端存储中,然后添加到页面的 DOM 中。
|
||||
|
||||
### 媒体流和字幕 API
|
||||
|
||||
用于流式传输视频数据、音频数据和视频字幕的 API 取决于所选的流协议(DASH、HLS),[下面将详细介绍](#streaming-protocols)。
|
||||
|
||||
### 视频播放器 API
|
||||
|
||||
视频播放器在[下面的一个专门的章节中介绍](#video-player-data-model-architecture-and-api)。
|
||||
|
||||
## 视频播放器数据模型、架构和 API
|
||||
|
||||
由于视频播放器涉及多个属性(状态)、导致状态变化的许多操作以及许多依赖于中央视频播放器状态的组件,因此单向 reducer + actions 模式是合适的。这可以使用 React 中的`useReducer`或 React + Redux 实现,其中 Redux 围绕操作和 reducer 提供了更多结构,以及用于增强开发人员体验的附加开发工具。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
* **视图**(UI 组件):进度控制、控制栏、媒体
|
||||
* **状态**(命名可能与实际 DOM 属性不同):播放器状态、缓冲帧、当前时间、持续时间、播放速率、当前时间戳、音量、静音、音频语言、字幕语言、音轨、视频轨道、字幕/文本轨道、海报、高度、宽度
|
||||
* **调度程序**:将操作调度到 reducer。或者,客户端可以直接从 UI 组件或事件处理程序中调度操作。
|
||||
* **操作**:播放、暂停、跳过、快进、调整音量、静音、切换全屏
|
||||
* **键盘事件**导致的操作:
|
||||
* 空格键 -> 播放/暂停
|
||||
* 音量键 -> 调整音量
|
||||
* 静音键 -> 静音
|
||||
* 箭头键 -> 跳过
|
||||
* 全屏快捷方式 -> 切换全屏
|
||||
* **后台事件**:`loadstart`、`loadeddata`、`ended`、`error`、`stalled`、`waiting`等。请参阅[`<video>`元素上可用的完整事件列表](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#events)。
|
||||
|
||||
[类似 Flux 的单向流模型](https://facebookarchive.github.io/flux/docs/in-depth-overview)在这里运行良好。在 reducer 模式中,`newState = reducer(action, state)`。操作是改变状态的操作。定义了可以修改状态的操作列表,即已知操作。更改状态的唯一方法是调度一个操作,没有直接更新状态的方法。这有助于将状态更改逻辑集中在 reducer 中。
|
||||
|
||||
操作也可以源自不同的来源——它们可以由各种 UI 元素、键盘事件或后台事件触发。reducer 不需要关心操作是从哪里调度的,它只需要接收一个操作 + 当前状态并返回新状态。
|
||||
|
||||
由于其播放器视频控件过度组件化以及某些交互导致额外的样式重新计算(由于循环依赖和内存泄漏),YouTube 遇到了性能问题。为了解决这个问题,YouTube 更新了视频播放器,通过将播放器重构为将数据传递给其子级的顶级组件来同步所有更新。这确保了任何状态更改只有一次 UI 更新(绘制),从而消除了链式更新。尽管 YouTube 不使用 React 或 Redux,但这种重构本质上是 Flux 类似 reducer 模式的实现。*来源:[构建更好的 Web - 第 1 部分:Web 上更快的 YouTube](https://web.dev/case-studies/better-youtube-web-part1)。*
|
||||
|
||||
2018 年,Netflix 将其视频播放器重写为 React 和 Redux,他们选择使用 Redux 以单源方式封装复杂的播放业务逻辑。Redux 是 Web UI 工程中一个众所周知的库/模式,它以满足其目标的方式促进了关注点的分离。通过将 Redux 与数据规范化相结合,除了提供标准化、可预测的表达复杂业务逻辑的方式外,他们还实现了跨团队的并行开发。*来源:[现代化 Web 播放 UI。自 2013 年以来,用户体验…… | Netflix 技术博客](https://netflixtechblog.com/modernizing-the-web-playback-ui-1ad2f184a5a0)。*
|
||||
|
||||
可以缓存缓冲的视频数据,特别是对于在会话中不会改变的广告牌视频。但是,客户端应注意缓冲的视频数据量,并在达到影响页面性能的程度时释放内存。
|
||||
|
||||
媒体播放器本质上也包含状态,因为 DOM 中的`HTMLVideoObject`包含`paused`、`muted`等属性。通过在 JavaScript 中构建您自己的视频播放器组件,将存在重复的状态值,并且随着重复,值可能会不同步。推荐的方法是让 UI 组件状态成为事实来源,并将组件状态与 DOM 媒体播放器状态同步,本质上使媒体播放器成为[“受控”组件](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable),类似于`<input>`元素在 React 中的控制方式。
|
||||
|
||||
以下是一些提供自定义视频播放器或帮助您构建视频播放器的库:
|
||||
|
||||
* [Shaka Player](https://github.com/shaka-project/shaka-player):一个用于自适应媒体的开源 JavaScript 库,支持 DASH 和 HLS。
|
||||
* [Video.js](https://videojs.com/):类似于 Shaka Player,具有许多不同的主题和皮肤。
|
||||
* [Media Chrome](https://www.media-chrome.org/):用于构建媒体播放器的元素。
|
||||
|
||||
教程:
|
||||
|
||||
* [移动 Web 视频播放 | 文章 | web.dev](https://web.dev/articles/media-mobile-web-video-playback)
|
||||
* [构建媒体播放器系列 | Chrome for Developers](https://www.youtube.com/watch?v=--KA2VrPDao\&list=PLNYkxOF6rcIBykcJ7bvTpqU7vt-oey72J\&index=20)
|
||||
|
||||
## 优化和深入研究
|
||||
|
||||
### 了解原生 HTML `<video>` 元素
|
||||
|
||||
HTML5 提供了一个 `<video>` 标签,用于在网页中播放视频。它是在 HTML5 中引入的,代表了 Web 标准的重大改进,允许直接嵌入视频,而无需像 Flash 这样的外部插件。在本节中,我们将介绍有关 `<video>` 标签的一些基础知识。
|
||||
|
||||
#### 渐进式下载
|
||||
|
||||
渲染视频的最简单方法类似于图像,其中 `src` 属性指向视频文件。
|
||||
|
||||
```html
|
||||
<video src="movie.mp4" />
|
||||
```
|
||||
|
||||
这种使用带有指向视频文件的 `src` 属性的 `<video>` 标签的方法称为“渐进式下载”。在渐进式下载中,视频文件以线性方式从服务器下载并同时播放。与仅将视频的必要部分发送给用户的流式传输不同,渐进式下载涉及下载整个文件,从头开始。只要下载了足够的数据以确保不间断播放,就可以播放视频。这种方法比真正的流式传输更简单,但需要更多的带宽和存储空间,因为会下载整个视频文件,而不管用户是否将其观看完毕。这看起来类似于流式传输,但在技术上并不是真正的流式传输。
|
||||
|
||||
可以通过使用 [HTTP `Range` 请求](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) 下载相应的片段来实现查找。HTTP `Range` 请求要求服务器仅将 HTTP 消息的一部分发送回客户端,这对于媒体播放器很有用,因为它希望支持文件的随机访问。
|
||||
|
||||
Netflix 和 YouTube 上播放的视频使用流式传输,而不是渐进式下载。它通过 [Media Source API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API) 结合自适应流式传输格式(如 HLS 和 DASH)和自适应比特率算法来实现,无论设备或网络状况如何,都能提供流畅的流式传输体验。有关更多信息,请参阅 [下方](#media-source-api)。
|
||||
|
||||
#### `<video>` 元素属性
|
||||
|
||||
支持的 HTML 属性包括:
|
||||
|
||||
* `src`:指定视频文件的来源。
|
||||
* `width` 和 `height`:定义网页上视频播放器的大小。
|
||||
* `controls`:添加视频控件,如播放、暂停和音量。
|
||||
* `autoplay`:导致视频在加载后立即开始播放(由于用户体验和带宽方面的考虑,不推荐使用)。
|
||||
* `loop`:使视频在每次播放完毕后重新开始。
|
||||
* `muted`:默认情况下使音频静音。
|
||||
* `poster`:指定在视频下载期间或用户点击播放按钮之前显示的图像。
|
||||
|
||||
当浏览器解析 HTML 时,大多数这些 HTML 属性都会成为 DOM 中 `HTMLVideoElement` 的属性。
|
||||
|
||||
`<video>` 元素还允许通过 `source` 标签指定多个视频源,以便浏览器可以选择最有效的格式。这样做是为了确保在各种浏览器中的兼容性,因为并非所有浏览器都支持相同的视频格式。
|
||||
|
||||
```html
|
||||
<video width="320" height="240" controls>
|
||||
<source src="movie.webm" type="video/webm" />
|
||||
<source src="movie.mp4" type="video/mp4" />
|
||||
<source src="movie.ogg" type="video/ogg" />
|
||||
您的浏览器不支持 video 标签。
|
||||
</video>
|
||||
```
|
||||
|
||||
放置在 `<video>` 标签之间(但在 `<source>` 标签之外)的文本用作不支持 `<video>` 标签的浏览器的后备内容。
|
||||
|
||||
#### `HTMLVideoElement` 方法
|
||||
|
||||
可以使用 JavaScript 操纵 `HTMLVideoElement` 元素以实现进一步的交互。`HTMLVideoElement` 继承自 `HTMLMediaElement` 接口,该接口提供了一系列方法,允许控制和与 HTML 中的媒体元素(如 `<audio>` 和 `<video>`)进行交互。`HTMLMediaElement` 上提供的一些重要方法:
|
||||
|
||||
* `play()`:此方法用于开始播放媒体。如果媒体已经在播放,则此方法无效。如果播放已暂停,它将恢复。
|
||||
* `pause()`:此方法暂停媒体播放。如果媒体已经暂停,则此方法无效。
|
||||
* `load()`:此方法用于重置媒体元素并重新加载源媒体。当媒体的来源发生变化时,它很有用。
|
||||
* `addTextTrack()`:向媒体元素添加新的文本轨道。这可以用于字幕、标题、描述、章节或元数据。
|
||||
* `fastSeek()`:此方法允许快速跳转到媒体中的特定时间点。
|
||||
|
||||
#### `HTMLVideoElement` 事件
|
||||
|
||||
`HTMLVideoElement` 继承自 `HTMLMediaElement` 接口,该接口提供了一系列事件,允许开发人员监视和控制媒体播放。这些事件对于在网页上创建交互式和响应式媒体体验至关重要。
|
||||
|
||||
以下是与 `HTMLMediaElement` 关联的一些重要事件:
|
||||
|
||||
* `loadstart`:当浏览器开始查找媒体时触发;加载过程的开始。
|
||||
* `loadeddata`:当媒体的第一帧加载完成并准备好播放时触发。
|
||||
* `progress`:浏览器加载媒体时定期触发。用于显示媒体加载进度。
|
||||
* `play`:当媒体播放开始或恢复时触发。
|
||||
* `playing`:当媒体在暂停或停止缓冲后实际开始播放时触发。
|
||||
* `pause`:当媒体播放暂停时发生。
|
||||
* `ended`:当播放停止,因为媒体已到达结尾时触发。
|
||||
* `waiting`:当由于暂时缺少数据而停止媒体播放时发生。
|
||||
* `stalled`:当媒体下载意外停止时触发,通常是由于网络问题。
|
||||
* `volumechange`:当音量发生变化时发生,包括当 `muted` 属性发生变化时。
|
||||
* `error`:在获取媒体时发生错误时触发。
|
||||
|
||||
这些事件对于创建媒体元素的详细控制界面、处理错误、跟踪进度和响应用户交互至关重要。通过将事件侦听器添加到这些事件,开发人员可以自定义方式管理媒体播放,还可以收集用户分析。
|
||||
|
||||
#### 使用 `<video>` 的缺点
|
||||
|
||||
使用“原始”`<video>`元素也有一些缺点:
|
||||
|
||||
* **有限的自适应流支持**:`<video>` 元素在所有浏览器中都不原生支持自适应流协议,如 DASH 或 HLS。这些协议根据用户的互联网速度动态调整视频质量,确保流畅的流媒体体验。如果没有这个,用户可能会遇到缓冲或低质量的视频。`<video>` 元素可能未针对低延迟至关重要的场景(例如直播活动)进行优化。
|
||||
|
||||
* **受限的自定义和控制**:`<video>` 元素也包含播放控件,但与大多数原生元素一样,每个浏览器呈现它们的方式都不同。如果您希望在浏览器之间拥有一致且有品牌的用户界面,则必须构建自己的播放控件。但是,像其他 HTML 元素(如 `<button>` 和 `<input>`)一样,设置这些控件的样式并不简单。您将不得不构建自己的组件。
|
||||
|
||||
请注意,`<video>` 元素也包含它们自己的状态,如上所述的属性/属性。如果您正在使用 JavaScript 框架/库(例如 React、Vue)并构建了自己的 `Video` 组件,该组件呈现 `<video>` 元素以及自定义控件,您将需要在 React 组件状态和 `<video>`/`HTMLVideoElement` 状态之间进行双向同步,因为可能存在直接影响 `HTMLVideoElement` 的原生控件,例如某些键盘上的播放/暂停/音量按钮(也称为媒体键)。
|
||||
|
||||
在以下示例中,React 组件状态与 DOM 视频状态同步。尝试使用“播放”按钮播放视频,并查看播放和自定义 UI 状态是否已正确同步。
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://codesandbox.io/embed/f6h325?fontsize=14&hidenavigation=1&theme=dark&module=/src/App.tsx&view=split"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 500,
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="React video component state sync"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
/>
|
||||
```
|
||||
|
||||
另请确保自定义构建的视频控件符合原生视频控件提供的可访问性要求和标准。
|
||||
|
||||
* **不支持高级功能**:视频预览、搜索时的缩略图、多比特率流和直播等功能在 `<video>` 元素中不受原生支持或受到限制。
|
||||
|
||||
由于这些缺点,很明显,要创建世界一流的视频流体验,自定义视频播放器 UI 是必经之路。
|
||||
|
||||
### 视频流
|
||||
|
||||
现在我们对使用渐进式下载的视频播放及其缺点有了更好的理解,我们可以讨论如何通过视频流实现世界一流的视频观看体验。
|
||||
|
||||
#### 媒体源 API
|
||||
|
||||
[媒体源 API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API)(正式名称为媒体源扩展 (MSE))是一个 Web API,它增强了 Web 应用程序中流媒体的功能。媒体源 API 允许将媒体元素中标准的单个渐进式 `src` URI 替换为 `MediaSource` 对象。此对象管理媒体的就绪状态,并引用多个 `SourceBuffer` 对象,表示媒体流的不同块。
|
||||
|
||||
```js
|
||||
// 设置视频元素和 MediaSource
|
||||
const videoEl = document.getElementById('my-video');
|
||||
const mediaSource = new MediaSource();
|
||||
|
||||
// 将 MediaSource 对象设置为视频元素的源。
|
||||
videoEl.src = URL.createObjectURL(mediaSource);
|
||||
mediaSource.addEventListener('sourceopen', sourceOpen);
|
||||
|
||||
async function sourceOpen() {
|
||||
// 使用特定的 MIME 类型创建一个源缓冲区。
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(
|
||||
'video/mp4; codecs="avc1.64001E"',
|
||||
);
|
||||
|
||||
sourceBuffer.addEventListener('updateend', () => {
|
||||
// 检查媒体源是否已结束以及是否有更多段
|
||||
// 您可以获取并附加其他段。
|
||||
if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
|
||||
mediaSource.endOfStream();
|
||||
} else {
|
||||
// 获取下一段。
|
||||
}
|
||||
});
|
||||
|
||||
// 获取视频的第一段。
|
||||
const response = await fetch('path/to/your/video/segment1.mp4');
|
||||
const segment = await response.arrayBuffer();
|
||||
// 将获取的段附加到源缓冲区。
|
||||
sourceBuffer.appendBuffer(segment);
|
||||
}
|
||||
```
|
||||
|
||||
分段视频文件以及媒体源 API 允许客户端流式传输视频内容。此 API 还允许创建更多交互式视频体验,例如能够动态插入广告、在多个视频角度之间切换或将其他内容与视频播放同步。[Netflix 的 Bandersnatch](https://postperspective.com/netflixs-black-mirror-bandersnatch-lets-viewers-choose/) 是一部交互式电影,有 5 种独特的结局,用户可以在观看时“选择自己的冒险”。因此,组合的数量是巨大的,并且为所有可能的电影路径创建视频文件是不可行的。使用 `MediaSource` 有助于根据用户的选择动态地将电影的不同部分拼接在一起。
|
||||
|
||||
如果您检查 Netflix 和 YouTube 上 `<video>` 元素的 `src` 属性,您会看到它们看起来像 `<video src="blob:https://www.netflix.com/b4bc251f-5b0d-47a3-b0cb-4fbf653a16f4">`。这是因为 `src` 是使用 `URL.createObjectURL()` 创建的。
|
||||
|
||||
阅读有关媒体源 API 的更多信息,请访问:
|
||||
|
||||
* [媒体源 API | 文章 | web.dev](https://web.dev/articles/media-mse-basics)
|
||||
* [媒体源 API | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API)
|
||||
|
||||
现在我们知道了视频流的工作原理,但这还不是全部!流媒体可以通过自适应比特率流进一步改进。
|
||||
|
||||
#### 自适应比特率流
|
||||
|
||||
虽然流媒体有助于改善视频播放体验,但它并没有考虑到客户端的设备和网络状况。如果用户使用的是不稳定的移动网络,他们将无法立即观看高分辨率视频,因为他们需要等待更长的时间来下载片段。当移动设备的屏幕尺寸不够宽,无法显示所有细节时,用户也无法从高分辨率视频中受益。
|
||||
|
||||
**自适应比特率流**是一种用于在线视频和音频流的技术,它可以动态调整视频的质量,以适应用户设备的可用带宽和处理能力。
|
||||
|
||||
客户端使用自适应比特率 (ABR) 算法自动选择具有最高比特率的片段,该片段可以在播放前及时下载,而不会导致播放过程中出现停顿或重新缓冲事件。
|
||||
|
||||
这些因素会实时监控并由算法使用:
|
||||
|
||||
* 可用带宽
|
||||
* 可用编解码器
|
||||
* 连接质量
|
||||
* 视频播放器尺寸
|
||||
* 播放速率
|
||||
|
||||
此决定是在视频播放过程中动态做出的,以适应不断变化的网络速度。
|
||||
|
||||
#### 媒体功能 API
|
||||
|
||||
通过 [媒体功能 API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Capabilities_API),网站可以获取更多关于客户端视频解码性能的信息,并就向用户提供哪种编解码器和分辨率做出明智的决定。
|
||||
|
||||
[YouTube 使用媒体功能 API](https://web.dev/case-studies/youtube-media-capabilities) 来防止其自适应比特率算法自动选择设备无法流畅播放的分辨率。
|
||||
|
||||
#### 流媒体协议
|
||||
|
||||
Web 上使用了两种流行的流媒体协议,可用于实现自适应比特率流:**通过 HTTP 的动态自适应流 (DASH)** 和 **HTTP 实时流 (HLS)**。
|
||||
|
||||
这些流媒体协议具有以下共同点:
|
||||
|
||||
* **分段媒体文件**:各种质量的媒体内容被分成小段,从而实现无缝流式传输以及在不同质量流之间切换的能力。
|
||||
* **基于 HTTP 的交付**:它们使用标准的 HTTP Web 服务器进行媒体交付,简化了分发并减少了对专用流媒体服务器的需求。使用 HTTP 获取文件将大部分逻辑从网络协议转移到客户端应用程序,因此媒体也可以从静态 CDN(如 Amazon S3)流式传输。
|
||||
* **清单文件**:每种协议都使用一种类型的清单文件(如 DASH 的 MPD,HLS 的 M3U8)来提供有关可用流、其分辨率、比特率和段位置(URL)的信息。
|
||||
|
||||
#### 动态自适应流 (DASH)
|
||||
|
||||
通过 HTTP 的动态自适应流 (DASH) 是一种流媒体协议和技术,它允许通过互联网高效地传输多媒体内容,例如视频和音频。DASH 旨在通过实时适应用户的网络状况和设备功能来优化用户的观看体验。
|
||||
|
||||
DASH 的其他功能:
|
||||
|
||||
1. **媒体呈现描述 (MPD)**:DASH 依赖于一种基于 XML 的清单文件,称为 [媒体呈现描述 (MPD)](https://ottverse.com/structure-of-an-mpeg-dash-mpd/)。MPD 包含有关视频内容的元数据,包括有关可用质量级别、段 URL 和其他属性的信息,这些信息指导视频播放器做出自适应流媒体决策。
|
||||
2. **延迟控制**:DASH 可以设计为根据特定用例控制延迟。低延迟 DASH (LL-DASH) 是一种扩展,它针对实时和交互式流媒体应用程序优化了协议。
|
||||
3. **互操作性**:DASH 专为跨不同设备和平台的互操作性而设计。因此,可以使用相同的 DASH 编码内容用于各种播放环境。
|
||||
|
||||
DASH 经常被许多流媒体服务使用,包括 Netflix、YouTube 和 Amazon Prime Video 等热门平台,以向用户提供高质量的流媒体体验。它有助于确保用户获得最佳的视频质量,同时适应不断变化的网络状况、设备功能和屏幕尺寸。这项技术在提高在线视频流的可靠性和性能方面发挥了重要作用。
|
||||
|
||||
[dash.js](https://reference.dashif.org/dash.js/) 库是通过 JavaScript 和兼容 MSE 平台的 DASH 播放的参考客户端实现。
|
||||
|
||||
#### HTTP 实时流 (HLS)
|
||||
|
||||
HTTP Live Streaming (HLS) 是一种流媒体协议和技术,由 Apple 开发,用于通过互联网传输多媒体内容,例如视频和音频。 HLS 广泛用于流式传输视频内容,尤其是在 iOS 设备(iPhone 和 iPad)以及 Web 浏览器和其他平台上。
|
||||
|
||||
HLS 的附加功能:
|
||||
|
||||
1. **M3U8 播放列表文件**:HLS 使用 M3U8 播放列表文件,这些文件是基于文本的清单文件,用于描述媒体内容并提供有关可用质量级别、分段 URL 和其他属性的信息。 播放列表文件托管在服务器上,客户端(视频播放器)使用它们来请求和播放媒体内容。
|
||||
2. **媒体加密**:HLS 可以使用各种加密方法支持媒体内容加密,以保护内容免受未经授权的访问。 这可以包括高级加密标准 (AES) 加密等方法。
|
||||
3. **兼容性**:HLS 与各种设备兼容,包括 iOS 设备、Web 浏览器、Android 设备、智能电视等。 许多媒体播放器和流媒体平台都支持 HLS。
|
||||
4. **低延迟模式**:在较新版本中,HLS 引入了低延迟模式,以减少直播事件和用户接收之间的延迟,使其适用于实时流媒体,包括体育赛事直播和在线游戏。
|
||||
5. **自适应流媒体服务器**:为了实现 HLS,通常使用专门的媒体服务器,例如 Apple 基于 macOS 的 HTTP Live Streaming 工具,或第三方服务器,如 Wowza Streaming Engine 和带有 `ngx_http_hls_module` 的 Nginx。
|
||||
|
||||
HLS 已成为流式传输视频内容的实际标准,尤其是在在线视频服务和直播领域。 由于其与 iOS 设备的兼容性及其自适应流媒体功能,它被广泛采用,这有助于确保用户在不同的网络条件和设备类型上获得高质量的观看体验。
|
||||
|
||||
M3U8 文件可以描述多种视频质量,允许播放器根据网络状况或用户偏好在不同的流之间切换。 这是 HTTP Live Streaming (HLS) 中自适应流媒体的一项关键功能。 这是一个具有多种视频质量的 M3U8 播放列表的示例:
|
||||
|
||||
#### 清单文件
|
||||
|
||||
清单文件是一个关键组件,它提供有关视频内容的基本信息,允许视频播放器正确播放视频。 清单文件指导视频播放器如何请求和显示视频片段。
|
||||
|
||||
各种流媒体协议中使用不同类型的清单文件,例如:
|
||||
|
||||
* **DASH**:在 DASH 中,清单文件称为 [Media Presentation Description (MPD)](https://www.brendanlong.com/the-structure-of-an-mpeg-dash-mpd.html) 文件。 该文件通常为 XML 格式,包含有关可用质量级别、分段 URL 和视频播放器请求和播放内容所需的其他属性的信息。
|
||||
* **HLS**:对于 HLS,清单文件称为媒体播放列表或 [M3U/M3U8](https://en.wikipedia.org/wiki/M3U) 文件。 它是一个纯文本文件,扩展名为 `.m3u8`,其中包含元数据和指向视频片段的 URL。
|
||||
|
||||
了解清单文件的确切格式并不重要,但您应该知道它们包含哪些详细信息。 清单文件包含有关视频流的详细信息,例如:
|
||||
|
||||
* **可用质量级别**:有关视频的不同比特率和分辨率的信息,允许播放器根据网络状况选择合适的质量。
|
||||
* **分段 URL**:指向各个视频片段或块的链接。 这些片段构成完整的视频,并由播放器根据需要请求以进行播放。
|
||||
* **播放时间和结构**:有关片段的顺序和持续时间的详细信息,允许播放器按正确的顺序组织和播放它们。
|
||||
* **自适应流媒体信息**:启用自适应流媒体的信息,确保播放器可以根据网络状况在不同的比特率或分辨率之间切换。
|
||||
* **音轨和字幕轨道**:有关视频的备用音轨和字幕选项的信息。
|
||||
|
||||
清单文件对于促进自适应流媒体过程和使播放器能够根据需要获取和播放视频片段至关重要,从而实现更流畅和不间断的观看体验。 它本质上充当播放器的指南,提供请求和呈现视频内容所需的信息。
|
||||
|
||||
Netflix 主要使用基于 DASH 的专有自适应比特率流媒体技术,类似于 HLS 等其他自适应流媒体协议,但具有一些针对 Netflix 大规模流媒体服务量身定制的独特功能和优化。 Netflix 不仅针对用户的带宽优化其流,还针对内容类型(如动作片与对话片)和所使用的设备(智能电视、智能手机、平板电脑等)进行优化。 YouTube 使用 DASH,但支持 HLS 用于某些应用程序,例如 HLS 更流行的 Apple 设备。
|
||||
|
||||
以下是用于 DASH 的 MPD 文件的简化示例:
|
||||
|
||||
```xml
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT6M16S" minBufferTime="PT1.5S">
|
||||
<Period start="PT0S">
|
||||
<AdaptationSet mimeType="video/mp4" segmentAlignment="true" startWithSAP="1">
|
||||
<Representation id="video_1" width="1920" height="1080" bandwidth="8000000" codecs="avc1.640028">
|
||||
<SegmentTemplate media="video_1_$Number$.m4s" initialization="video_1_init.m4s" duration="4" timescale="1" startNumber="1"/>
|
||||
</Representation>
|
||||
<Representation id="video_2" width="1280" height="720" bandwidth="4000000" codecs="avc1.64001f">
|
||||
<SegmentTemplate media="video_2_$Number$.m4s" initialization="video_2_init.m4s" duration="4" timescale="1" startNumber="1"/>
|
||||
</Representation>
|
||||
<!-- More Representations for different resolutions and bitrates -->
|
||||
</AdaptationSet>
|
||||
<AdaptationSet mimeType="audio/mp4" lang="en">
|
||||
<Representation id="audio_1" bandwidth="128000" codecs="mp4a.40.2">
|
||||
<SegmentTemplate media="audio_1_$Number$.m4s" initialization="audio_1_init.m4s" duration="4" timescale="1" startNumber="1"/>
|
||||
</Representation>
|
||||
<!-- More Representations for different audio qualities -->
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>
|
||||
```
|
||||
|
||||
在此示例中,MPD 文件描述了一个具有两个视频质量选项(1080p 和 720p)和一个音轨的视频。 每个 `Representation` 元素提供有关内容的特定版本(包括分辨率、比特率、编解码器和分段文件的命名模式 (`SegmentTemplate`))的详细信息。 客户端播放器使用此信息根据当前的播放条件选择最合适的流。
|
||||
|
||||
在 HLS 中使用时,M3U8 文件可以描述多种视频质量,允许播放器根据网络状况或用户偏好在不同的流之间切换。 这是一个具有多种视频质量的 M3U8 播放列表的示例:
|
||||
|
||||
```txt
|
||||
#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
|
||||
http://example.com/low.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=960x540
|
||||
http://example.com/medium.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
|
||||
http://example.com/high.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
|
||||
http://example.com/hd.m3u8
|
||||
```
|
||||
|
||||
以及 `http://example.com/low.m3u8` 可能包含的示例:
|
||||
|
||||
```txt
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
|
||||
#EXTINF:10.0,
|
||||
http://example.com/low/segment0.ts
|
||||
#EXTINF:10.0,
|
||||
http://example.com/low/segment1.ts
|
||||
#EXTINF:10.0,
|
||||
http://example.com/low/segment2.ts
|
||||
|
||||
#EXT-X-ENDLIST
|
||||
```
|
||||
|
||||
请注意,`.ts` 扩展名是 MPEG-2 传输流文件,其中包含媒体流的一个片段。它不是 TypeScript 文件!
|
||||
|
||||
#### 资源
|
||||
|
||||
* [设置自适应流媒体源 - 开发者指南](https://developer.mozilla.org/en-US/docs/Web/Guide/Audio_and_video_delivery/Setting_up_adaptive_streaming_media_sources)
|
||||
* [Netflix 如何率先进行单片视频编码优化 - 流媒体学习中心](https://streaminglearningcenter.com/encoding/how-netflix-pioneered-per-title-video-encoding-optimization.html)
|
||||
|
||||
### 字幕/隐藏式字幕
|
||||
|
||||
字幕和隐藏式字幕都提供屏幕文本来伴随视频内容,但它们服务于不同的目的和受众:
|
||||
|
||||
字幕主要供可以听到音频但听不懂视频中所说语言的用户使用。通常,字幕只包括对话或口语,除此之外的内容不多。它们供非聋哑或听力不好的人使用。
|
||||
|
||||
隐藏式字幕专为聋哑或听力不好的人设计。隐藏式字幕不仅包括对话,还包括配乐的其他相关部分,例如音效、背景噪音和音乐提示。它们还指示谁在说话或注意重要的声音。它们对无法听到视频音频的人特别有帮助。
|
||||
|
||||
差异很微妙,但了解它们很有用。从现在开始,我们将字幕作为一个通用术语,表示伴随视频内容的屏幕文本。
|
||||
|
||||
#### 分离的字幕文件
|
||||
|
||||
字幕通常以单独的文件提供,这些文件与视频一起下载和显示。最常见的字幕文件格式包括:
|
||||
|
||||
1. **SubRip Subtitle (SRT)**:一种简单且广泛支持的格式,包含带时间戳的文本。
|
||||
2. **Timed Text Markup Language (TTML)**:一种基于 XML 的字幕和字幕格式。
|
||||
3. **Scenarist Closed Caption (SCC)**:一种用于隐藏式字幕和广播视频字幕的格式。
|
||||
4. **WebVTT (VTT)**:一种提供更多样式选项的格式,通常用于 HTML5 视频。您可以在 HTML5 `<video>` 元素中使用 `<track>` 元素来引用 WebVTT 文件,浏览器会处理字幕的渲染。
|
||||
|
||||
```html
|
||||
<video controls>
|
||||
<source src="video.mp4" type="video/mp4" />
|
||||
<track
|
||||
label="English"
|
||||
kind="subtitles"
|
||||
srclang="en"
|
||||
src="subtitles.vtt"
|
||||
default />
|
||||
</video>
|
||||
```
|
||||
|
||||
#### 嵌入式字幕
|
||||
|
||||
在某些情况下,字幕直接嵌入到视频文件本身中。此方法常用于广播和流媒体视频格式,如 DVB、ATSC 和一些流媒体协议。字幕由视频播放器解码和显示。
|
||||
|
||||
DASH 和 HLS 支持将字幕作为流媒体包的一部分提供。字幕包含在清单文件中,用户可以通过视频播放器进行选择。
|
||||
|
||||
#### 单独的 API
|
||||
|
||||
一些网络视频播放器库提供 API,允许开发人员从外部来源或服务动态加载字幕。这对于字幕根据用户偏好或动态内容而变化的应用非常有用。
|
||||
|
||||
#### 辅助功能
|
||||
|
||||
显示字幕时,确保它们对所有用户(包括残疾用户)都可访问非常重要。这包括为屏幕阅读器和键盘导航提供适当的标记和交互支持。它还包括使用户能够自定义字幕的外观,例如字体大小和颜色,以提高可读性。
|
||||
|
||||
除了口语对话外,字幕和文字记录还应识别传达重要信息的音乐和音效。这包括情感和语调:
|
||||
|
||||
```txt
|
||||
14
|
||||
00:03:14 --> 00:03:18
|
||||
[Dramatic rock music]
|
||||
|
||||
15
|
||||
00:03:19 --> 00:03:21
|
||||
[whispering] What's that off in the distance?
|
||||
```
|
||||
|
||||
#### 多语言支持
|
||||
|
||||
为了迎合全球观众,视频播放器通常允许用户从多种语言和字幕轨道中选择,从而可以在不同的语言或字幕样式之间切换。
|
||||
|
||||
#### 资源
|
||||
|
||||
* [在 Netflix 上实现日语字幕 | Netflix 技术博客](https://netflixtechblog.com/implementing-japanese-subtitles-on-netflix-c165fbe61989)
|
||||
* [Web Video Text Tracks Format (WebVTT) - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API)
|
||||
* [字幕、字幕、WebVTT、HLS 和那些神奇的标志 | Mux](https://www.mux.com/blog/subtitles-captions-webvtt-hls-and-those-magic-flags)
|
||||
* [向 HTML 视频添加字幕和字幕 - 开发者指南 | MDN](https://developer.mozilla.org/en-US/docs/Web/Guide/Audio_and_video_delivery/Adding_captions_and_subtitles_to_HTML5_video)
|
||||
* [WebAIM:字幕、文字记录和音频描述](https://webaim.org/techniques/captions/)
|
||||
|
||||
### 性能
|
||||
|
||||
视频流的性能对于流畅和愉快的观看体验至关重要,没有缓冲和质量问题。对于企业而言,流媒体的高性能对于客户保留、品牌声誉和高效的带宽使用至关重要,这会影响运营成本和可扩展性。
|
||||
|
||||
#### 最小化延迟
|
||||
|
||||
* **视频加载时间**:减少视频加载时间以最大限度地减少缓冲延迟。使用自适应流技术根据用户的网络状况提供适当的质量。
|
||||
* **缓冲优化**:优化视频缓冲以提供流畅的播放。尽可能预加载视频内容,并使用高效的缓冲算法。在当前时间戳之前缓冲。
|
||||
* **网络效率**:利用自适应比特率流来根据用户可用的带宽调整视频质量。这确保了连接速度较慢的用户仍然可以观看视频(尽管质量较低),而不会频繁缓冲。
|
||||
* **视频压缩**:使用现代视频压缩编解码器(例如,H.264、H.265、VP9)来最小化视频文件的大小,从而减少需要在 Internet 上传输的数据量。
|
||||
* **CDN 使用**:利用内容交付网络 (CDN) 从更靠近用户的位置的服务器提供视频内容,从而减少延迟并提高播放性能。
|
||||
* **延迟加载**:对视频实现延迟加载,以便它们仅在进入用户的视口时加载。在空闲周期或与之交互时,延迟加载不可见的 UI,如下拉列表和模态。这减少了初始页面加载时间。
|
||||
* **播放器响应能力**:确保视频播放期间视频播放器控件和用户界面保持响应。用户应该能够在没有延迟的情况下与播放器交互。
|
||||
* **预加载媒体文件**:通过使用 `preload` 属性(仅适用于预定义的 `src`,与 Media Source API 不兼容)或使用链接预加载来预加载媒体 ([source](https://web.dev/articles/fast-playback-with-preload))。
|
||||
|
||||
#### 通过将视频生命周期与 UI 生命周期分离来提高视频启动时间
|
||||
|
||||
允许 UI 组件树控制视频播放生命周期的传统方法可能会导致用户体验迟缓。这主要是由于对 UI 生命周期方法的依赖,例如 React 中的方法,其中视频初始化与特定的组件调用相关联,导致用户必须等待,直到播放充分加载后才能查看内容。
|
||||
|
||||
一种更有效的替代方法涉及将管理视频播放的逻辑与 UI 组件树分离。这允许从应用程序内的任何点执行与视频相关的流程,包括在初始应用程序加载期间 UI 树呈现之前。通过与 UI 渲染并行启动视频创建,应用程序获得了宝贵的时间来创建、初始化和缓冲视频播放。因此,这种方法使用户能够更快地开始播放视频,从而增强整体响应能力和用户体验。
|
||||
|
||||
*来源:[现代化 Web 播放 UI | Netflix 技术博客](https://netflixtechblog.com/modernizing-the-web-playback-ui-1ad2f184a5a0)*
|
||||
|
||||
#### 分离音频和视频流
|
||||
|
||||
可以分离音频和视频流,以便即使音频发生变化(例如,当音频更改为不同的语言时),也可以重用视频流。这种细粒度的分离允许进行其他优化,例如当用户在后台选项卡中播放视频(例如,歌词视频的常见情况)时,不需要流式传输视频数据。
|
||||
|
||||
#### 图像优化
|
||||
|
||||
* **预加载海报图像**:`<link as="image" rel="preload" href="poster.jpg" fetchpriority="high">`。
|
||||
* **视频缩略图**:优化视频缩略图的生成和显示,这有助于加快加载时间并改善用户在视频中搜索时的体验。
|
||||
* **响应式图像**:使用响应式缩略图图像来加载适合当前设备的尺寸的图像。
|
||||
|
||||
#### 带宽效率
|
||||
|
||||
* **选择性自动播放**:在新的标签页中打开视频时,YouTube 在标签页获得焦点之前不会开始播放视频。
|
||||
* **不可见的标签页**:当标签页在后台且不可见时,只需要流式传输音频数据。
|
||||
* **充分缓冲但不过度**:不要缓冲超过必要的内容,尤其是在视频暂停时,因为用户可能无意恢复观看。
|
||||
|
||||
#### 内存使用
|
||||
|
||||
观看视频需要页面长期存在,因此注意内存使用和效率非常重要。
|
||||
|
||||
* **内存使用**: 尽量减少内存消耗,以防止随着时间的推移性能下降,尤其是在 RAM 有限的设备上。当标签页在后台时,不流式传输视频数据有助于降低缓冲区中的数据量,从而保持较低的内存使用率。
|
||||
* **资源清理**: 当不再需要视频时,正确释放资源,包括视频缓冲区和内存,以防止内存泄漏和性能问题。
|
||||
|
||||
### 用户体验
|
||||
|
||||
积极的视频流媒体用户体验至关重要,因为它确保了用户从用户的角度获得满意度和参与度。对于企业而言,这意味着更高的用户保留率、品牌忠诚度和潜在的收入增长,因为满意的用户更有可能推荐该服务并继续订阅。
|
||||
|
||||
#### 易用性
|
||||
|
||||
* **播放控制**: 播放器应提供基本的播放控制,包括播放/暂停、音量控制、静音和全屏模式。这些控件应易于访问和响应。
|
||||
* **一致的用户界面**: 在不同的设备和平台上保持一致且熟悉的用户界面。用户应该对播放器的布局和控件感到舒适,并且它们的位置应符合通用标准。
|
||||
* **响应式设计**: 视频播放器应适应各种屏幕尺寸和方向,确保其在台式机、笔记本电脑、平板电脑和移动设备上都能正常工作。
|
||||
* **搜索和擦洗**: 确保搜索(在视频中向前或向后移动)简单而精确。用户应该能够准确地浏览视频。
|
||||
|
||||
#### 自定义
|
||||
|
||||
* **自定义选项**: 允许用户自定义播放器的各个方面,例如字幕、字幕、视频质量和播放速度,以满足他们的偏好。
|
||||
* **视频质量设置**: 为用户提供根据其互联网连接和设备功能调整视频质量的选项。这可以帮助防止缓冲问题并提供更流畅的观看体验。
|
||||
* **播放速度控制**: 有些用户更喜欢以更快或更慢的速度观看视频。包括一个选项来调整播放速度以满足个人喜好。
|
||||
* **错误处理**: 当出现播放问题时(例如无法加载视频或在播放期间遇到错误),显示清晰且内容丰富的错误消息。
|
||||
|
||||
#### 增强体验
|
||||
|
||||
* **防止布局偏移**: 在`<video>`标签上设置`width`和`height`属性,以[防止布局偏移](https://web.dev/patterns/web-vitals-patterns/video/video)。
|
||||
* **视频缩略图预览**: 当用户将鼠标悬停在时间轴上时,提供视频缩略图或预览。这有助于用户快速识别视频中的特定场景。
|
||||
* **海报图片**: 对于自动播放的视频,[YouTube 发现使用纯黑色海报图片](https://web.dev/case-studies/better-youtube-web-part1#improving_core_web_vitals)对于自动播放的视频来说是一个更好的体验,因为从纯黑色到视频的第一帧的过渡不太刺眼。
|
||||
|
||||
### 可访问性 (a11y)
|
||||
|
||||
由于视频流媒体应用程序服务于广泛的国际受众,因此可访问性至关重要,因为它确保所有用户(包括残疾用户)都能平等地访问内容。高标准的可访问性也有助于企业维护数字内容消费中的平等和非歧视原则。
|
||||
|
||||
#### 字幕
|
||||
|
||||
字幕的可访问性已在上面的[“字幕”](#subtitles--closed-captions)部分中介绍。回顾一下:
|
||||
|
||||
* **听力障碍问题**: 应为视频提供隐藏式字幕或字幕,以帮助失聪或听力障碍的用户。
|
||||
* **实施**: 实现字幕的一种方法是在`<video>`标签中使用`<track>`标签。
|
||||
* **可读性**: 确保字幕易于阅读,具有清晰的字体、适当的大小以及与背景视频形成高对比度。常见的选择是带有阴影的白色文本或以深色突出显示的白色文本。
|
||||
* **包含非语音元素**: 不仅要捕捉对话,还要捕捉字幕中的重要声音和音频提示,以提供对内容的更全面的理解。
|
||||
* **自定义选项**: 支持字幕的显示和自定义,包括字体大小、颜色和背景。
|
||||
* **多语言支持**: 字幕应以多种语言提供,以满足具有不同语言背景的多元化受众的需求。
|
||||
|
||||
#### 视觉辅助
|
||||
|
||||
* **对比度和颜色选择**: 确保视频播放器的用户界面(包括控件和文本)满足最低对比度比率,以使其更易于阅读和交互。避免仅依赖颜色来传达重要信息。
|
||||
* **加载进度指示器**: 显示加载进度条或微调器,以告知用户视频内容正在加载。这可以帮助管理用户期望并减少挫败感。
|
||||
* **缓冲指示器**: 清楚地表明视频何时正在缓冲,以管理用户期望并提供有关加载过程的反馈。
|
||||
* **网站功能的描述**: Netflix 向屏幕阅读器用户描述了视频的功能。
|
||||
|
||||

|
||||
|
||||
#### 屏幕阅读器
|
||||
|
||||
* **按钮有标签**: 为视频控件提供替代文本,为按钮(通常仅限图标)提供描述性标签。
|
||||
* **视频播放信息**: 屏幕阅读器用户应接收有关视频的相关信息,例如标题、持续时间和播放状态。
|
||||
* **文本到语音兼容性**: 视频播放器不应干扰或中断将屏幕文本转换为语音的辅助技术。
|
||||
|
||||
#### 键盘支持
|
||||
|
||||
* **键盘辅助功能**: 视频播放器应仅使用键盘导航即可操作。这包括允许用户使用键盘快捷键播放、暂停、调整音量和在视频中搜索。键盘焦点应可见且合乎逻辑。
|
||||
* **键盘快捷键**: 为常见操作提供键盘快捷键,例如音量控制、播放和在视频中搜索。
|
||||
* **焦点管理**: 保持清晰且合乎逻辑的焦点顺序,确保键盘和屏幕阅读器用户可以轻松地浏览控件而不会卡住。
|
||||
|
||||
#### 外部控制源
|
||||
|
||||
* **多种输入方式**:确保视频播放器可以使用多种输入方式操作,包括触摸屏和指向设备。
|
||||
* **外部外设**:外部外设也可以控制媒体对象的状态,Web 用户界面不是唯一的控制源。媒体对象的状态应与 UI 组件状态同步,以便自定义控件可以准确地反映播放状态。
|
||||
|
||||
*来源:[无障碍多媒体 - 学习 Web 开发 | MDN](https://developer.mozilla.org/zh-CN/docs/Learn/Accessibility/Multimedia#accessible_audio_and_video_controls)*
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
Web 视频播放器的国际化问题涉及确保播放器被设计和开发,以支持来自不同地区和语言的用户。
|
||||
|
||||
* **语言支持**:用户界面应翻译成多种语言,以支持全球受众。使用 i18next 或 `react-intl` 等国际化库或框架来管理语言翻译。
|
||||
* **按钮的翻译标签**:像 `aria-label` 这样的不可见按钮标签也应该为用户选择的语言进行翻译。
|
||||
* **字幕和字幕**:支持多种字幕和隐藏式字幕语言。来自不同地区的用户可能需要使用他们的母语字幕才能完全理解内容。
|
||||
* **音轨选择**:如果视频提供多种音频选项(例如配音),则为用户提供选择不同语言音轨的功能。
|
||||
* **基于地区的内容**:由于许可或地区法规的限制,某些内容可能会受到地域限制。播放器应根据用户的位置处理内容可用性。
|
||||
* **RTL(从右到左)支持**:如果支持具有从右到左书写系统(例如阿拉伯语或希伯来语)的语言,请确保视频播放器的界面在必要时适应 RTL 布局。
|
||||
* **内容元数据的本地化**:如果视频播放器显示有关内容的元数据(例如标题和描述),请确保此信息可以本地化,并为不同地区准确呈现。
|
||||
* **内容评级和指南**:某些地区有特定的内容评级系统和指南。确保内容评级和警告符合当地法规和标准。
|
||||
* **法律和合规性**:了解国际版权和知识产权法。确保视频播放器和内容分发符合当地法规和许可协议。
|
||||
|
||||
### 奖励
|
||||
|
||||
#### 如何在悬停在进度条上时显示缩略图
|
||||
|
||||
* YouTube 为每个时间戳创建低分辨率图像,并将几个帧拼接在一起,形成一个上传到 CDN 的精灵图。当悬停在进度条上时,会发出请求以获取包含当前时间戳缩略图的精灵图。
|
||||
* Netflix 将缩略图作为其流数据的一部分。
|
||||
|
||||
## 参考
|
||||
|
||||
* [视频的工作原理](https://howvideo.works/)
|
||||
* [构建更好的 Web - 第 1 部分:Web 上更快的 YouTube](https://web.dev/better-youtube-web-part1/)
|
||||
* [YouTube 如何使用媒体功能 API 改进视频性能](https://web.dev/youtube-media-capabilities/)
|
||||
* [媒体源 API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API)
|
||||
* [UI 框架和媒体元素](https://medium.com/axon-enterprise/ui-frameworks-and-media-elements-c0c6832528e5)
|
||||
* [现代化 Web 播放 UI | Netflix 技术博客](https://netflixtechblog.com/modernizing-the-web-playback-ui-1ad2f184a5a0)
|
||||
* [设置自适应流媒体源 - 开发者指南](https://developer.mozilla.org/en-US/docs/Web/Guide/Audio_and_video_delivery/Setting_up_adaptive_streaming_media_sources)
|
||||
Loading…
Reference in New Issue