خلاصه
بیاموزید که چگونه یک برنامه تک صفحه ای را با استفاده از اجزای وب، پلیمر و طراحی متریال ساختیم و آن را در Google.com به تولید رساندیم.
نتایج
- تعامل بیشتر از برنامه بومی (4:06 دقیقه وب موبایل در مقابل اندروید 2:40 دقیقه).
- 450 میلیثانیه سریعتر رنگآمیزی اولیه برای کاربران بازگشتی به لطف ذخیرهسازی سرویسدهنده
- 84 درصد از بازدیدکنندگان از Service Worker حمایت کردند
- ذخیرههای افزودن به صفحه اصلی در مقایسه با سال 2015 900٪ افزایش یافته است.
- 3.8 درصد کاربران آفلاین شدند اما همچنان 11 هزار بازدید از صفحه ایجاد کردند!
- 50 درصد از کاربرانی که وارد سیستم شدهاند اعلانها را فعال کردهاند.
- 536 هزار اعلان برای کاربران ارسال شد (12 درصد آنها را بازگرداندند).
- 99 درصد از مرورگرهای کاربران از polyfills اجزای وب پشتیبانی می کردند
نمای کلی
امسال، من این لذت را داشتم که روی برنامه وب پیشرو Google I/O 2016 کار کنم که با محبت "IOWA" نام داشت. ابتدا تلفن همراه است، کاملاً آفلاین کار می کند و به شدت از طراحی متریال الهام گرفته شده است.
IOWA یک برنامه کاربردی تک صفحه ای (SPA) است که با استفاده از کامپوننت های وب ، پلیمر، و Firebase ساخته شده است و دارای یک Backend گسترده است که در App Engine (Go) نوشته شده است. با استفاده از یک سرویسکار ، محتوا را از پیش ذخیره میکند، صفحات جدید را بهصورت پویا بارگیری میکند، بهراحتی بین نماها جابهجا میشود و پس از بارگیری مجدد از محتوا استفاده میکند.
در این مطالعه موردی، برخی از تصمیمات معماری جالبتری را که برای قسمت جلویی گرفتهایم مرور میکنم. اگر به کد منبع علاقه دارید، آن را در Github بررسی کنید .
ساخت SPA با استفاده از اجزای وب
هر صفحه به عنوان یک جزء
یکی از جنبه های اصلی در مورد frontend ما این است که حول اجزای وب متمرکز شده است. در واقع، هر صفحه در SPA ما یک جزء وب است:
<io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
<io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
<io-attend-page></io-attend-page>
<io-extended-page></io-extended-page>
<io-faq-page></io-faq-page>
چرا این کار را کردیم؟ اولین دلیل این است که این کد قابل خواندن است. به عنوان اولین خواننده، کاملاً واضح است که هر صفحه در برنامه ما چیست. دلیل دوم این است که کامپوننت های وب ویژگی های خوبی برای ساخت SPA دارند. بسیاری از ناامیدی های رایج (مدیریت حالت، فعال سازی مشاهده، محدوده سبک) به لطف ویژگی های ذاتی عنصر <template>
، عناصر سفارشی و Shadow DOM از بین می روند. اینها ابزارهای توسعه دهنده ای هستند که در مرورگر ساخته می شوند. چرا از آنها سوء استفاده نمی کنید؟
با ایجاد یک عنصر سفارشی برای هر صفحه، چیزهای زیادی به صورت رایگان دریافت کردیم:
- مدیریت چرخه عمر صفحه
- محدوده CSS/HTML مخصوص صفحه.
- تمام CSS/HTML/JS مختص یک صفحه بسته بندی شده و در صورت نیاز با هم بارگذاری می شوند.
- نماها قابل استفاده مجدد هستند. از آنجایی که صفحات گره های DOM هستند، به سادگی افزودن یا حذف آن ها نمای را تغییر می دهد.
- نگهبانان آینده می توانند برنامه ما را به سادگی با علامت گذاری درک کنند.
- با ثبت و ارتقاء تعاریف عناصر توسط مرورگر، نشانه گذاری ارائه شده توسط سرور می تواند به تدریج افزایش یابد.
- عناصر سفارشی یک مدل ارثی دارند. کد DRY کد خوبی است.
- ... خیلی چیزهای بیشتر.
ما از این مزایا در IOWA نهایت استفاده را بردیم. بیایید به برخی از جزئیات شیرجه بزنیم.
فعال سازی پویا صفحات
عنصر <template>
روش استاندارد مرورگر برای ایجاد نشانه گذاری قابل استفاده مجدد است. <template>
دو ویژگی دارد که SPAها می توانند از آنها بهره ببرند. اول، هر چیزی در داخل <template>
بی اثر است تا زمانی که نمونه ای از الگو ایجاد شود. دوم، مرورگر نشانه گذاری را تجزیه می کند اما محتوا از صفحه اصلی قابل دسترسی نیست. این یک تکه نشانه گذاری واقعی و قابل استفاده مجدد است. به عنوان مثال:
<template id="t">
<div>This markup is inert and not part of the main page's DOM.</div>
<img src="profile.png"> <!-- not loaded by the browser -->
<video id="vid" src="vid.mp4"></video> <!-- doesn't load/start -->
<script>alert("Not run until the template is stamped");</script>
</template>
پلیمر <template>
را با چند عنصر سفارشی پسوند نوع گسترش می دهد ، یعنی <template is="dom-if">
و <template is="dom-repeat">
. هر دو عنصر سفارشی هستند که <template>
با قابلیت های اضافی گسترش می دهند. و به لطف ماهیت اعلامی اجزای وب، هر دو دقیقاً همان کاری را انجام می دهند که انتظار دارید. مؤلفه اول نشانه گذاری را بر اساس شرطی مهر می کند. دومی نشانه گذاری را برای هر مورد در یک لیست (مدل داده) تکرار می کند.
IOWA چگونه از این المانهای پسوندی استفاده میکند؟
اگر به خاطر داشته باشید، هر صفحه در IOWA یک جزء وب است. با این حال، احمقانه است که هر جزء را در بار اول اعلام کنیم . این به معنای ایجاد یک نمونه از هر صفحه در اولین بارگیری برنامه است. ما نمیخواستیم به عملکرد بارگذاری اولیه خود آسیبی وارد کنیم، به خصوص که برخی از کاربران فقط به 1 یا 2 صفحه پیمایش میکنند.
راه حل ما تقلب بود. در IOWA، عنصر هر صفحه را در یک <template is="dom-if">
قرار می دهیم تا محتویات آن در اولین بوت بارگذاری نشود. سپس زمانی که ویژگی name
قالب با URL مطابقت داشته باشد، صفحات را فعال می کنیم. مؤلفه وب <lazy-pages>
همه این منطق را برای ما مدیریت می کند. نشانه گذاری چیزی شبیه به این است:
<!-- Lazy pages manages the template stamping. It watches for route changes
and sets `template.if = true` on the appropriate template. -->
<lazy-pages>
<template is="dom-if" name="home">
<io-home-page date="2016-05-18T17:00:00Z"></io-home-page>
</template>
<template is="dom-if" name="schedule">
<io-schedule-page date="2016-05-18T17:00:00Z"></io-schedule-page>
</template>
<template is="dom-if" name="attend">
<io-attend-page></io-attend-page>
</template>
</lazy-pages>
چیزی که من در این مورد دوست دارم این است که هر صفحه وقتی صفحه بارگیری می شود، تجزیه می شود و آماده کار است، اما CSS/HTML/JS آن فقط بر اساس تقاضا اجرا می شود (زمانی که <template>
مادر آن مهر شده باشد). نماهای پویا + تنبل با استفاده از اجزای وب FTW.
پیشرفت های آینده
هنگامی که صفحه برای اولین بار بارگیری می شود، ما در حال بارگیری همه واردات HTML برای هر صفحه به یکباره هستیم. یک پیشرفت آشکار این است که تعاریف عناصر را تنها در زمانی که به آنها نیاز است بارگذاری کنند. Polymer همچنین یک کمک کننده خوب برای بارگذاری ناهمگام واردات HTML دارد:
Polymer.Base.importHref('io-home-page.html', (e) => { ... });
IOWA این کار را انجام نمی دهد زیرا الف) ما تنبل شدیم و ب) مشخص نیست که چقدر افزایش عملکرد را شاهد بودیم. اولین رنگ ما قبلاً 1 ثانیه بود.
مدیریت چرخه عمر صفحه
Custom Elements API « بازخوانی چرخه حیات » را برای مدیریت وضعیت یک جزء تعریف می کند. هنگامی که این روش ها را پیاده سازی می کنید، قلاب های رایگان در طول عمر یک جزء دریافت می کنید:
createdCallback() {
// automatically called when an instance of the element is created.
}
attachedCallback() {
// automatically called when the element is attached to the DOM.
}
detachedCallback() {
// automatically called when the element is removed from the DOM.
}
attributeChangedCallback() {
// automatically called when an HTML attribute changes.
}
استفاده از این تماس ها در IOWA آسان بود. به یاد داشته باشید، هر صفحه یک گره DOM مستقل است. پیمایش به یک "نمای جدید" در SPA ما مربوط به اتصال یک گره به DOM و حذف یک گره دیگر است.
ما از attachedCallback
برای انجام کار راهاندازی (وضعیت اولیه، پیوست شنوندگان رویداد) استفاده کردیم. هنگامی که کاربران به صفحه دیگری هدایت میشوند، detachedCallback
پاکسازی میکند (حذف شنوندگان، بازنشانی حالت اشتراکگذاری شده). ما همچنین تماسهای چرخه حیات بومی را با چندین مورد خود گسترش دادیم:
onPageTransitionDone() {
// page transition animations are complete.
},
onSubpageTransitionDone() {
// sub nav/tab page transitions are complete.
}
اینها اضافات مفیدی برای به تاخیر انداختن کار و به حداقل رساندن jank بین انتقال صفحه بودند. بیشتر در این مورد بعدا.
خشک کردن عملکرد رایج در سراسر صفحات
وراثت یکی از ویژگی های قدرتمند Custom Elements است. این یک مدل ارثی استاندارد برای وب فراهم می کند.
متأسفانه، پلیمر 1.0 هنوز در زمان نگارش وراثت عنصر را پیاده سازی نکرده است. در این بین، ویژگی Polymer's Behaviors به همان اندازه مفید بود. رفتارها فقط ترکیب هستند.
به جای ایجاد سطح API یکسان در همه صفحات، خشک کردن پایگاه کد با ایجاد میکس های مشترک منطقی بود. برای مثال، PageBehavior
ویژگیها/روشهای رایجی را که همه صفحات در برنامه ما به آن نیاز دارند، تعریف میکند:
PageBehavior.html
let PageBehavior = {
// Common properties all pages need.
properties: {
name: { type: String }, // Slug name of the page.
...
},
attached() {
// If the page defines a `onPageTransitionDone`, call it when the router
// fires 'page-transition-done'.
if (this.onPageTransitionDone) {
this.listen(document.body, 'page-transition-done', 'onPageTransitionDone');
}
// Update page meta data when new page is navigated to.
document.body.id = `page-${this.name}`;
document.title = this.title || 'Google I/O 2016';
// Scroll to top of new page.
if (IOWA.Elements.Scroller) {
IOWA.Elements.Scroller.scrollTop = 0;
}
this.setupSubnavEffects();
},
detached() {
this.unlisten(document.body, 'page-transition-done', 'onPageTransitionDone');
this.teardownSubnavEffects();
}
};
IOWA.IOBehaviors = IOWA.IOBehaviors || {PageBehavior: PageBehavior};
همانطور که می بینید، PageBehavior
وظایف رایجی را انجام می دهد که هنگام بازدید از یک صفحه جدید اجرا می شوند. مواردی مانند بهروزرسانی document.title
، بازنشانی موقعیت اسکرول، و تنظیم شنوندگان رویداد برای جلوههای پیمایش و زیر پیمایش.
صفحات منفرد از PageBehavior
با بارگذاری آن به عنوان یک وابستگی و استفاده از behaviors
استفاده می کنند. آنها همچنین آزادند تا در صورت نیاز، ویژگی ها/روش های پایه آن را نادیده بگیرند. به عنوان مثال، در اینجا چیزی است که "subclass" صفحه اصلی ما لغو می شود:
io-home-page.html
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="PageBehavior.html">
<!-- rest of the import dependencies used by the page. -->
<dom-module id="io-home-page">
<template>
<!-- PAGE'S MARKUP -->
</template>
<script>
Polymer({
is: 'io-home-page',
behaviors: [IOBehaviors.PageBehavior], // All pages have common functionality.
// Pages define their own title and slug for the router.
title: 'Schedule - Google I/O 2016',
name: 'home',
// The home page has custom setup work when it's added navigated to.
// Note: PageBehavior's attached also gets called.
attached() {
if (this.app.isPhoneSize) {
this.listen(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
}
},
// The home page does its own cleanup when a new page is navigated to.
// Note: PageBehavior's detached also gets called.
detached() {
this.unlisten(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
},
// The home page can define onPageTransitionDone to do extra work
// when page transitions are done, and thus preventing janky animations.
onPageTransitionDone() {
...
}
});
</script>
</dom-module>
به اشتراک گذاری سبک ها
برای اشتراکگذاری استایلها در اجزای مختلف در برنامهمان، از ماژولهای سبک مشترک Polymer استفاده کردیم. ماژولهای Style به شما این امکان را میدهند که یکبار تکهای از CSS را تعریف کنید و از آن در مکانهای مختلف در سراسر یک برنامه استفاده مجدد کنید. برای ما «مکان های مختلف» به معنای اجزای مختلف بود.
در IOWA، ما shared-app-styles
ایجاد کردیم تا رنگها، تایپوگرافی و کلاسهای چیدمان را در صفحات و سایر اجزای سازنده به اشتراک بگذاریم.
shared-app-styles.html
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../bower_components/paper-styles/color.html">
<dom-module id="shared-app-styles">
<template>
<style>
[layout] {
@apply(--layout);
}
[layout][horizontal] {
@apply(--layout-horizontal);
}
.scrollable {
@apply(--layout-scroll);
}
.noscroll {
overflow: hidden;
}
/* Style radio buttons and tabs the same throughout the app */
paper-tabs {
--paper-tabs-selection-bar-color: currentcolor;
}
paper-radio-button {
--paper-radio-button-checked-color: var(--paper-cyan-600);
--paper-radio-button-checked-ink-color: var(--paper-cyan-600);
}
...
</style>
</template>
</dom-module>
io-home-page.html
<link rel="import" href="shared-app-styles.html">
<!-- Rest of import dependencies used by the page. -->
<dom-module id="io-home-page">
<template>
<style include="shared-app-styles">
:host { display: block} /* Other element styles can go here. */
</style>
<!-- PAGE'S MARKUP -->
</template>
<script>Polymer({...});</script>
</dom-module>
در اینجا، <style include="shared-app-styles"></style>
نحو Polymer برای گفتن "شامل سبک ها در ماژول به نام "shared-app-styles" است.
به اشتراک گذاری وضعیت برنامه
اکنون می دانید که هر صفحه در برنامه ما یک عنصر سفارشی است. میلیون بار گفتم خوب، اما اگر هر صفحه جزء وب مستقل باشد، ممکن است از خود بپرسید که چگونه وضعیت را در برنامه به اشتراک می گذاریم.
IOWA از تکنیکی مشابه تزریق وابستگی (Angular) یا redux (React) برای به اشتراک گذاری وضعیت استفاده می کند. ما یک ویژگی app
جهانی ایجاد کردیم و ویژگی های فرعی مشترک را از آن آویزان کردیم. app
با تزریق آن به هر مؤلفه ای که به داده های آن نیاز دارد به برنامه ما منتقل می شود. استفاده از ویژگی های اتصال داده پلیمر این کار را آسان می کند زیرا می توانیم سیم کشی را بدون نوشتن هیچ کدی انجام دهیم:
<lazy-pages>
<template is="dom-if" name="home">
<io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
</template>
<template is="dom-if" name="schedule">
<io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
</template>
...
</lazy-pages>
<google-signin client-id="..." scopes="profile email"
user="{ % templatetag openvariable % }app.currentUser}}"></google-signin>
<iron-media-query query="(min-width:320px) and (max-width:768px)"
query-matches="{ % templatetag openvariable % }app.isPhoneSize}}"></iron-media-query>
عنصر <google-signin>
هنگامی که کاربران به برنامه ما وارد می شوند، ویژگی user
خود را به روز می کند. از آنجایی که این ویژگی به app.currentUser
محدود شده است، هر صفحه ای که می خواهد به کاربر فعلی دسترسی داشته باشد، به سادگی باید به app
متصل شود و ویژگی فرعی currentUser
را بخواند. این تکنیک به خودی خود برای به اشتراک گذاری وضعیت در سراسر برنامه مفید است. با این حال، یک مزیت دیگر این بود که ما در نهایت یک عنصر ورود به سیستم ایجاد کردیم و از نتایج آن در سراسر سایت استفاده مجدد کردیم . برای پرسش های رسانه ای هم همینطور. کپی کردن ورود به سیستم یا ایجاد مجموعه ای از درخواست های رسانه ای خود برای هر صفحه بیهوده بود. در عوض، اجزایی که مسئول عملکرد/داده های گسترده برنامه هستند در سطح برنامه وجود دارند.
انتقال صفحه
همانطور که در برنامه وب Google I/O پیمایش می کنید، متوجه تغییر صفحه نرم آن خواهید شد ( آلا طراحی متریال ).
هنگامی که کاربران به یک صفحه جدید هدایت می شوند، یک سری چیزها اتفاق می افتد:
- پیمایش بالای نوار انتخاب را به پیوند جدید می کشاند.
- عنوان صفحه محو می شود.
- محتوای صفحه به پایین می لغزد و سپس محو می شود.
- با معکوس کردن آن انیمیشن ها، عنوان و محتوای صفحه جدید ظاهر می شود.
- (اختیاری) صفحه جدید کارهای اولیه اضافی را انجام می دهد.
یکی از چالشهای ما این بود که بفهمیم چگونه میتوان این انتقال نرم را بدون قربانی کردن عملکرد ایجاد کرد. کارهای پویای زیادی وجود دارد که اتفاق می افتد و جنک در مهمانی ما مورد استقبال قرار نگرفت. راه حل ما ترکیبی از Web Animations API و Promises بود. استفاده از این دو با هم به ما تطبیق پذیری، سیستم پویانمایی plug and play و کنترل گرانول برای به حداقل رساندن das jank داد.
چگونه کار می کند
هنگامی که کاربران روی یک صفحه جدید کلیک می کنند (یا به عقب/ جلو می زنند)، runPageTransition()
روتر ما جادوی خود را با اجرای یک سری Promises انجام می دهد. استفاده از Promises به ما این امکان را داد که انیمیشن ها را با دقت هماهنگ کنیم و به منطقی کردن «ناهمگام بودن» انیمیشن های CSS و بارگذاری پویا محتوا کمک کرد.
class Router {
init() {
window.addEventListener('popstate', e => this.runPageTransition());
}
runPageTransition() {
let endPage = this.state.end.page;
this.fire('page-transition-start'); // 1. Let current page know it's starting.
IOWA.PageAnimation.runExitAnimation() // 2. Play exist animation sequence.
.then(() => {
IOWA.Elements.LazyPages.selected = endPage; // 3. Activate new page in <lazy-pages>.
this.state.current = this.parseUrl(this.state.end.href);
})
.then(() => IOWA.PageAnimation.runEnterAnimation()) // 4. Play entry animation sequence.
.then(() => this.fire('page-transition-done')) // 5. Tell new page transitions are done.
.catch(e => IOWA.Util.reportError(e));
}
}
از بخش "DRY نگه داشتن چیزها: عملکرد مشترک در بین صفحات" را به خاطر بیاورید ، صفحات به رویدادهای DOM page-transition-start
و page-transition-done
گوش می دهند. اکنون شما می بینید که آن رویدادها از کجا شروع می شوند.
ما از Web Animations API به جای راهنماهای runEnterAnimation
/ runExitAnimation
استفاده کردیم. در مورد runExitAnimation
، ما چند گره DOM (منطقه مستطیل و محتوای اصلی) را می گیریم، شروع/پایان هر انیمیشن را اعلام می کنیم و یک GroupEffect
ایجاد می کنیم تا این دو به صورت موازی اجرا شوند:
function runExitAnimation(section) {
let main = section.querySelector('.slide-up');
let masthead = section.querySelector('.masthead');
let start = {transform: 'translate(0,0)', opacity: 1};
let end = {transform: 'translate(0,-100px)', opacity: 0};
let opts = {duration: 400, easing: 'cubic-bezier(.4, 0, .2, 1)'};
let opts_delay = {duration: 400, delay: 200};
return new GroupEffect([
new KeyframeEffect(masthead, [start, end], opts),
new KeyframeEffect(main, [{opacity: 1}, {opacity: 0}], opts_delay)
]);
}
فقط آرایه را تغییر دهید تا انتقال های مشاهده بیشتر (یا کمتر) دقیق تر شود!
جلوه های اسکرول
هنگامی که صفحه را اسکرول می کنید، IOWA چند جلوه جالب دارد. اولین مورد ، دکمه اکشن شناور ما (FAB) است که کاربران را به بالای صفحه برمی گرداند:
<a href="#" tabindex="-1" aria-hidden="true" aria-label="back to top" onclick="backToTop">
<paper-fab icon="io:expand-less" noink tabindex="-1"></paper-fab>
</a>
اسکرول صاف با استفاده از عناصر طرحبندی برنامه پلیمر اجرا میشود. آنها جلوههای اسکرول خارج از جعبه را ارائه میکنند، مانند نوارهای پیمایش چسبنده/بازگردان، سایههای رها، انتقال رنگ و پسزمینه، جلوههای اختلاف منظر، و پیمایش صاف.
// Smooth scrolling the back to top FAB.
function backToTop(e) {
e.preventDefault();
Polymer.AppLayout.scroll({top: 0, behavior: 'smooth',
target: document.documentElement});
e.target.blur(); // Kick focus back to the page so user starts from the top of the doc.
}
مکان دیگری که ما از عناصر <app-layout>
استفاده کردیم، برای ناوبری چسبنده بود. همانطور که در ویدیو می بینید، وقتی کاربران صفحه را به پایین اسکرول می کنند، از بین می رود و هنگام اسکرول کردن به بالا برمی گردد.
ما از عنصر <app-header>
تقریباً همانطور که هست استفاده کردیم. رها کردن و به دست آوردن جلوه های اسکرول فانتزی در برنامه آسان بود. مطمئناً، ما خودمان میتوانستیم آنها را پیادهسازی کنیم، اما داشتن جزئیات از قبل در یک جزء قابل استفاده مجدد، صرفهجویی زیادی در زمان داشت.
عنصر را اعلام کنید. آن را با ویژگی ها سفارشی کنید. شما تمام شده اید!
<app-header reveals condenses effects="fade-background waterfall"></app-header>
نتیجه گیری
برای برنامه وب پیشرو I/O، به لطف اجزای وب و ابزارک های طراحی متریال از پیش ساخته پلیمر، توانستیم یک صفحه اصلی را در چند هفته بسازیم. ویژگی های API های بومی (Custom Elements، Shadow DOM، <template>
) به طور طبیعی به پویایی یک SPA کمک می کند. قابلیت استفاده مجدد باعث صرفه جویی در زمان می شود.
اگر علاقه مند به ایجاد یک برنامه وب پیشرو خود هستید، جعبه ابزار برنامه را بررسی کنید. Polymer's App Toolbox مجموعه ای از کامپوننت ها، ابزارها و قالب ها برای ساختن PWA با پلیمر است. این یک راه آسان برای بلند شدن و دویدن است.