1. מבוא
רכיבי אינטרנט
רכיבי אינטרנט הם אוסף של תקני אינטרנט שמאפשרים למפתחים להרחיב את HTML באמצעות רכיבים מותאמים אישית. ב-Codelab הזה נגדיר את רכיב <brick-viewer>, שיוכל להציג מודלים של לבנים.
lit-element
כדי להגדיר את הרכיב המותאם אישית <brick-viewer>, נשתמש ב-lit-element. lit-element הוא מחלקה בסיסית קלה שמוסיפה קצת סוכר תחבירי לתקן של רכיבי האינטרנט. כך יהיה לנו קל יותר להתחיל להשתמש ברכיב המותאם אישית.
שנתחיל?
נתכנת בסביבת Stackblitz אונליין, לכן צריך לפתוח את הקישור הזה בחלון חדש:
stackblitz.com/edit/brick-viewer
קדימה, מתחילים!
2. הגדרת רכיב בהתאמה אישית
הגדרת הכיתה
כדי להגדיר רכיב בהתאמה אישית, יוצרים מחלקה שמרחיבה את LitElement ומקשטים אותה ב-@customElement. הארגומנט של @customElement יהיה השם של הרכיב המותאם אישית.
ב-brick-viewer.ts, מוסיפים:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}
עכשיו, רכיב <brick-viewer></brick-viewer> מוכן לשימוש ב-HTML. אבל אם תנסו, שום דבר לא יוצג. יחד נפתור את זה.
שיטת הרינדור
כדי להטמיע את התצוגה של הרכיב, מגדירים שיטה בשם render. השיטה הזו צריכה להחזיר תבנית מילולית שתויגה באמצעות הפונקציה html. מציבים את קוד ה-HTML הרצוי בתוך התבנית המילולית המתויגת. התצוגה הזו תופיע כשמשתמשים ב-<brick-viewer>.
מוסיפים את השיטה render:
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick viewer</div>`;
}
}
3. ציון קובץ LDraw
הגדרת נכס
יהיה נהדר אם משתמש ב-<brick-viewer> יוכל לציין איזה מודל של לבנה להציג באמצעות מאפיין, כמו בדוגמה הבאה:
<brick-viewer src="path/to/model.ldraw"></brick-viewer>
מכיוון שאנחנו יוצרים רכיב HTML, אנחנו יכולים להשתמש ב-API הדקלרטיבי ולהגדיר מאפיין מקור, בדיוק כמו בתג <img> או <video>. עם lit-element, קל מאוד להוסיף מאפיין מחלקה באמצעות @property. האפשרות type מאפשרת לכם לציין איך lit-element מנתח את המאפיין לשימוש כמאפיין HTML.
מגדירים את הנכס src ואת המאפיין:
export class BrickViewer extends LitElement {
@property({type: String})
src: string|null = null;
}
לתג <brick-viewer> יש עכשיו מאפיין src שאפשר להגדיר ב-HTML! הערך שלו כבר קריא מתוך המחלקה BrickViewer שלנו, הודות ל-lit-element.
הצגת ערכים
אפשר להציג את הערך של מאפיין src באמצעות שימוש בו בליטרל התבנית של שיטת הרינדור. אפשר לבצע אינטרפולציה של ערכים לתוך ליטרלים של תבניות באמצעות התחביר ${value}.
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick model: ${this.src}</div>`;
}
}
עכשיו, הערך של מאפיין src מוצג ברכיב <brick-viewer> בחלון. נסו לפתוח את הכלים למפתחים בדפדפן ולשנות ידנית את מאפיין ה-src. קדימה, כדאי לנסות...
…שמתם לב שהטקסט ברכיב מתעדכן אוטומטית? lit-element עוקב אחרי מאפייני המחלקה שמקושטים ב-@property ומבצע רינדור מחדש של התצוגה בשבילכם. lit-element עושה את העבודה הקשה, כך שאתם לא צריכים.
4. הגדרת הסצנה באמצעות Three.js
אורות, מצלמה, עיבוד!
הרכיב המותאם אישית שלנו ישתמש ב-three.js כדי לעבד את מודלי הלבנים בתלת-ממד. יש פעולות שרוצים לבצע רק פעם אחת לכל מופע של רכיב <brick-viewer>, כמו הגדרת הסצנה, המצלמה והתאורה ב-three.js. נוסיף אותם לקונסטרוקטור של המחלקה BrickViewer. נשמור חלק מהאובייקטים כמאפייני מחלקה כדי שנוכל להשתמש בהם בהמשך: camera, scene, controls ו-renderer.
מוסיפים את ההגדרה של סצנת three.js:
export class BrickViewer extends LitElement {
private _camera: THREE.PerspectiveCamera;
private _scene: THREE.Scene;
private _controls: OrbitControls;
private _renderer: THREE.WebGLRenderer;
constructor() {
super();
this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
this._camera.position.set(150, 200, 250);
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0xdeebed);
const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
this._scene.add( ambientLight );
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-1000, 1200, 1500);
this._scene.add(directionalLight);
this._renderer = new THREE.WebGLRenderer({antialias: true});
this._renderer.setPixelRatio(window.devicePixelRatio);
this._renderer.setSize(this.offsetWidth, this.offsetHeight);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.addEventListener("change", () =>
requestAnimationFrame(this._animate)
);
this._animate();
const resizeObserver = new ResizeObserver(this._onResize);
resizeObserver.observe(this);
}
private _onResize = (entries: ResizeObserverEntry[]) => {
const { width, height } = entries[0].contentRect;
this._renderer.setSize(width, height);
this._camera.aspect = width / height;
this._camera.updateProjectionMatrix();
requestAnimationFrame(this._animate);
};
private _animate = () => {
this._renderer.render(this._scene, this._camera);
};
}
האובייקט WebGLRenderer מספק רכיב DOM שמציג את סצנת three.js שעברה עיבוד. הגישה אליו היא דרך הנכס domElement. אפשר להציב את הערך הזה בתוך תבנית המחרוזת המילולית של הרינדור באמצעות התחביר ${value}.
מסירים את ההודעה src שהייתה בתבנית, ומוסיפים את רכיב ה-DOM של כלי הרינדור:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
`;
}
}
כדי לאפשר הצגה של כל רכיב ה-DOM של רכיב הרינדור, צריך להגדיר גם את האלמנט <brick-viewer> עצמו ל-display: block. אנחנו יכולים לספק סגנונות בנכס סטטי שנקרא styles, שמוגדר כ-css template literal.
מוסיפים את הסגנון הזה לכיתה:
export class BrickViewer extends LitElement {
static styles = css`
/* The :host selector styles the brick-viewer itself! */
:host {
display: block;
}
`;
}
עכשיו <brick-viewer> מוצגת סצנת three.js שעברה רינדור:

אבל... הוא ריק. בואו נספק לו מודל.
רכיב טעינה לבנים
נעביר את הנכס src שהגדרנו קודם ל-LDrawLoader, שמועבר עם three.js.
בקובצי LDraw אפשר להפריד מודל של לבנה לשלבי בנייה נפרדים. אפשר לגשת למספר הכולל של השלבים ולניראות של כל לבנה באמצעות LDrawLoader API.
מעתיקים את המאפיינים האלה, את שיטת _loadModel החדשה ואת השורה החדשה ב-constructor:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
private _loader = new LDrawLoader();
private _model: any;
private _numConstructionSteps?: number;
step?: number;
constructor() {
// ...
// Add this line right before this._animate();
(this._loader as any).separateObjects = true;
this._animate();
}
private _loadModel() {
if (this.src === null) {
return;
}
this._loader
.setPath('')
// Using our src property!
.load(this.src, (newModel) => {
if (this._model !== undefined) {
this._scene.remove(this._model);
this._model = undefined;
}
this._model = newModel;
// Convert from LDraw coordinates: rotate 180 degrees around OX
this._model.rotation.x = Math.PI;
this._scene.add(this._model);
this._numConstructionSteps = this._model.userData.numConstructionSteps;
this.step = this._numConstructionSteps!;
const bbox = new THREE.Box3().setFromObject(this._model);
this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
this._controls.update();
this._controls.saveState();
});
}
}
מתי כדאי להתקשר אל _loadModel? צריך להפעיל אותו בכל פעם שמשנים את מאפיין ה-src. הוספנו את @property לנכס src כדי להוסיף את הנכס למחזור החיים של העדכון של lit-element. בכל פעם שהערך של אחת מהמאפיינים המעוטרים האלה משתנה, נקראת סדרה של שיטות שיכולות לגשת לערכים החדשים והישנים של המאפיינים. שיטת מחזור החיים שמעניינת אותנו נקראת update. השיטה update מקבלת ארגומנט PropertyValues שמכיל מידע על נכסים שהשתנו לאחרונה. זה המקום המושלם להתקשר אליו _loadModel.
מוסיפים את השיטה update:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
super.update(changedProperties);
}
}
רכיב <brick-viewer> שלנו יכול עכשיו להציג קובץ brick, שצוין באמצעות המאפיין src.

5. הצגת מודלים חלקיים
עכשיו נגדיר את שלב הבנייה הנוכחי. היינו רוצים לציין <brick-viewer step="5"></brick-viewer>, ולראות איך נראה מודל הלבנים בשלב החמישי של הבנייה. כדי לעשות את זה, נגדיר את המאפיין step כמאפיין שנצפה על ידי הוספת הקישוט @property.
הוספת קישוט לנכס step:
export class BrickViewer extends LitElement {
@property({type: Number})
step?: number;
}
עכשיו נוסיף שיטת עזר שתגרום לכך שרק הלבנים עד לשלב הבנייה הנוכחי יהיו גלויות. נקרא לפונקציית העזר בשיטת העדכון כדי שהיא תפעל בכל פעם שהמאפיין step משתנה.
מעדכנים את ה-method update ומוסיפים את ה-method החדשה _updateBricksVisibility:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
if (changedProperties.has('step')) {
this._updateBricksVisibility();
}
super.update(changedProperties);
}
private _updateBricksVisibility() {
this._model && this._model.traverse((c: any) => {
if (c.isGroup && this.step) {
c.visible = c.userData.constructionStep <= this.step;
}
});
requestAnimationFrame(this._animate);
}
}
אוקיי, עכשיו פותחים את כלי הפיתוח של הדפדפן ובודקים את הרכיב <brick-viewer>. מוסיפים לו מאפיין step, כך:

צופים במה שקורה למודל המעובד. אפשר להשתמש במאפיין step כדי לקבוע כמה מהמודל יוצג. כך זה אמור להיראות כשהמאפיין step מוגדר לערך "10":

6. ניווט בערכת לבנים
mwc-icon-button
משתמש הקצה של <brick-viewer> צריך גם להיות מסוגל לנווט בשלבי ה-build דרך ממשק המשתמש. נוסיף כפתורים למעבר לשלב הבא, לשלב הקודם ולשלב הראשון. כדי להקל על התהליך, נשתמש ברכיב האינטרנט של כפתור Material Design. הנתונים של @material/mwc-icon-button כבר יובאו, אז אפשר להוסיף את <mwc-icon-button></mwc-icon-button>. אפשר לציין את הסמל שרוצים להשתמש בו באמצעות מאפיין הסמל, כך: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. כאן אפשר למצוא את כל הסמלים האפשריים.
בואו נוסיף כמה לחצני סמלים לשיטת הרינדור:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button icon="replay"></mwc-icon-button>
<mwc-icon-button icon="navigate_before"></mwc-icon-button>
<mwc-icon-button icon="navigate_next"></mwc-icon-button>
</div>
`;
}
}
השימוש ב-Material Design בדף שלנו הוא פשוט כל כך, הודות לרכיבי האינטרנט!
קישורי אירועים
הכפתורים האלה צריכים לבצע פעולה כלשהי. הלחצן 'תשובה' אמור לאפס את שלב הבנייה ל-1. הלחצן navigate_before צריך להקטין את שלב הבנייה, והלחצן navigate_next צריך להגדיל אותו. lit-element מאפשר להוסיף את הפונקציונליות הזו בקלות באמצעות קשירת אירועים. במחרוזת התבנית של ה-HTML, משתמשים בתחביר @eventname=${eventHandler} כמאפיין של רכיב. הפונקציה eventHandler תפעל עכשיו כשאירוע eventname יזוהה באלמנט הזה. לדוגמה, נוסיף גורמים מטפלים באירועים מסוג קליק לשלושת לחצני הסמלים:
export class BrickViewer extends LitElement {
private _restart() {
this.step! = 1;
}
private _stepBack() {
this.step! -= 1;
}
private _stepForward() {
this.step! += 1;
}
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
<mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
<mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
</div>
`;
}
}
אפשר לנסות ללחוץ על הכפתורים עכשיו. יפה מאוד!
סגנונות
הכפתורים פועלים, אבל הם לא נראים טוב. כולם מתגודדים למטה. נעצב אותם כך שיוצגו כשכבת-על בסצנה.
כדי להחיל סגנונות על הלחצנים האלה, חוזרים למאפיין static styles. הסגנונות האלה מוגבלים להיקף, כלומר הם יחולו רק על רכיבים בתוך רכיב האינטרנט הזה. זה אחד היתרונות של כתיבת רכיבי אינטרנט: הסלקטורים יכולים להיות פשוטים יותר, ויהיה קל יותר לקרוא ולכתוב CSS. ביי ביי, BEM!
מעדכנים את הסגנונות כך שייראו כך:
export class BrickViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
#controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
}
`;
}

לחצן איפוס המצלמה
משתמשי הקצה ב-<brick-viewer> שלנו יכולים לסובב את הסצנה באמצעות פקדי העכבר. בזמן שאנחנו מוסיפים לחצנים, נוסיף גם לחצן לאיפוס המצלמה למיקום ברירת המחדל שלה. אפשר להשתמש בעוד <mwc-icon-button> עם קשירת אירוע מסוג קליק.
export class BrickViewer extends LitElement {
private _resetCamera() {
this._controls.reset();
}
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add this button: -->
<mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
</div>
`;
}
}
ניווט מהיר יותר
חלק ממערכות הלבנים כוללות הרבה שלבים. יכול להיות שהמשתמש ירצה לדלג לשלב מסוים. הוספת פס הזזה עם מספרי שלבים יכולה לעזור בניווט מהיר. לצורך זה נשתמש ברכיב <mwc-slider>.
mwc-slider
רכיב פס ההזזה צריך כמה נתונים חשובים, כמו הערך המינימלי והמקסימלי של פס ההזזה. הערך המינימלי של פס ההזזה יכול להיות תמיד 1. אם המודל נטען, הערך המקסימלי של פס ההזזה צריך להיות this._numConstructionSteps. אנחנו יכולים לדעת את הערכים האלה של <mwc-slider> דרך המאפיינים שלו. אפשר גם להשתמש בifDefined הנחיית lit-html כדי להימנע מהגדרת המאפיין max אם המאפיין _numConstructionSteps לא הוגדר.
מוסיפים <mwc-slider> בין הלחצנים 'הקודם' ו'הבא':
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... backwards button -->
<!-- Add this slider: -->
<mwc-slider
step="1"
pin
markers
min="1"
max=${ifDefined(this._numConstructionSteps)}
></mwc-slider>
<!-- ... forwards button -->
</div>
`;
}
}
הנתונים 'פעילים'
כשמשתמש מזיז את פס ההזזה, השלב הנוכחי בבנייה צריך להשתנות, והחשיפה של המודל צריכה להתעדכן בהתאם. רכיב פס ההזזה ישלח אירוע קלט בכל פעם שגוררים את פס ההזזה. מוסיפים קשירת אירוע לפס ההזזה עצמו כדי לתפוס את האירוע הזה ולשנות את שלב הבנייה.
מוסיפים את הקישור לאירוע:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add the @input event binding: -->
<mwc-slider
...
@input=${(e: CustomEvent) => this.step = e.detail.value}
></mwc-slider>
<!-- ... -->
</div>
`;
}
}
יש! אפשר להשתמש בפס ההזזה כדי לשנות את השלב שמוצג.
הנתונים לא זמינים
יש עוד דבר אחד. כשמשתמשים בלחצנים 'הקודם' ו'הבא' כדי לשנות את השלב, צריך לעדכן את נקודת האחיזה של פס ההזזה. קישור של מאפיין הערך של <mwc-slider> אל this.step.
מוסיפים את הקישור value:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add the value property binding: -->
<mwc-slider
...
value=${ifDefined(this.step)}
></mwc-slider>
<!-- ... -->
</div>
`;
}
}
כמעט סיימנו עם פס ההזזה. מוסיפים סגנון flex כדי שהרכיב יפעל בצורה חלקה עם שאר אמצעי הבקרה:
export class BrickViewer extends LitElement {
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
}
בנוסף, צריך להפעיל את layout ברכיב של פס ההזזה עצמו. אנחנו עושים את זה בשיטת מחזור החיים firstUpdated, שמופעלת אחרי ש-DOM מוגדר בפעם הראשונה. הדקורטור query יכול לעזור לנו לקבל הפניה לרכיב ה-slider בתבנית.
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
}
כאן אפשר לראות את כל התוספים של פס ההזזה (עם מאפייני pin ו-markers נוספים בפס ההזזה כדי שייראה מגניב):
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
<mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
<mwc-slider
step="1"
pin
markers
min="1"
max=${ifDefined(this._numConstructionSteps)}
?disabled=${this._numConstructionSteps === undefined}
value=${ifDefined(this.step)}
@input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
></mwc-slider>
<mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
<mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
</div>
`;
}
}
הנה המוצר הסופי!

7. סיכום
למדנו הרבה על השימוש ב-lit-element כדי לבנות רכיב HTML משלנו. הסברנו איך:
- הגדרת רכיב מותאם אישית
- הצהרה על מאפיין API
- איך מעבדים תצוגה של רכיב בהתאמה אישית
- כיסוי מלא
- שימוש באירועים ובמאפיינים להעברת נתונים
מידע נוסף על lit-element זמין באתר הרשמי.
אפשר לראות רכיב Brick Viewer שהושלם בכתובת stackblitz.com/edit/brick-viewer-complete.
הכלי brick-viewer נשלח גם ב-NPM, ואפשר לראות את המקור שלו כאן: מאגר GitHub.