React Server Components Nedir?
2020 yılının bitiminde aralık ayında React geliştiricileri Server Components adında bir geliştirim yönteminden bahsettiler. Bu geliştirim yöntemini de bir video ve RFC doküman duyurdular. Bu yazıda da video ve RFC dokümanında belirtilen yenilikler ele alınacaktır. Öncelikle yazıya başlamadan o videoyu izlemenizi tavsiye ederim.
Sunucu bileşenleri Nedir?
Sunucu bileşenleri (server components), geliştiricilere sunucu ve istemci tarafı kapsayacak şekilde uygulama geliştirimi sağlayan bir özelliktir. Bu sayede istemci tarafının sunduğu zengin etkileşim ortamı ile birikte geleneksel sunucu taraflı render’lama gerçekleştirilerek performanslı bir uygulama üretilebilmektedir.
Sunucu bileşenlerinin özellikleri:
- Sadece sunucu tarafında çalıştığı için uygulama boyutuna etkisi sıfırdır.
- Veritabanına, dosya sistemine ve (mikro)servislere direkt olarak erişim yapabilirler.
- İstemci bileşenleri (Client Components) ile uyumlu bir biçimde entegre edilebilir ve çalışabilirler. Örneğin sunucu bileşenleri, veriyi sunucuda yükleyebilir ve istemci bileşenlerine props aracılığıyla bu veriyi aktarabilirler. Bu sayede istemci bileşeni sayfanın sadece interaktif kısımları ile ilgilenebilir.
- Dinamik olarak hangi istemci bileşenini render edeceğini seçebilirler. Bu sayede istemci tarafında sayfayı render etmek için gereken minimum kod kadarlık veri aktarılmış olur.
- Tekrar yüklendiklerinde(reload) istemci tarafının durumu (state) korunur. Böylece sunucu bileşen ağacı tekrar yüklendiğinde, istemcinin state’i, focus’lanan input elemanı ve halihazırda gerçekleşen animasyonlar iptal edilmez/yarıda kalmaz.
- Sürekli bir şekilde render edilirler ve UI bileşenlerinin render edilmiş halini de sürekli olarak istemci tarafına stream ederler. Bu özellik React Suspense ile birleştirildiğinde, “yükleniyor…” durumlarının etkili bir şekilde oluşturulması ve sayfada öne çıkarılacak içeriğin hızlıca görüntülenmesi sağlanır.
- Geliştiriciler, sunucu ve istemci arasında kod paylaşımını gerçekleştirebilirler. Böylece bir içerik, web sitesinin herhangi bir sayfasında, sunucu tarafından render edilerek statik bir şekilde sayfaya basılabileceği gibi, diğer başka bir sayfada ise istemci tarafında render işlemi gerçekleştirilerek düzenlenebilir bir verisyonu oluşturulabilir.
Basit bir örnek
Aşağıda basit bir not uygulaması projesinden alınmış olan bir Note
bileşeni bulunmaktadır. Bu bileşende, div elemanı içerisinde <h1>
ve <section>
yazan kısımlar sunucu tarafında render edilirken, <NoteEditor>
bileşeni, klasik React bileşenleri gibi istemci tarafında render edilmektedir. Bu render işleminde isEditing
değişkeni bir feature flag (özellik bayrağı) gibi çalışarak ve NoteEditor
bileşenini isteğe bağlı bir şekilde render etmektedir.
Öncelikle sunucuda render edilen aşağıdaki Note bileşenini ele alalım. Sunucu bileşenlerini isimlendirmek için .server.js
.server.jsx
ve .server.tsx
uzantıları kullanılabilir.
// Note.server.js - Sunucu bileşeni
import db from 'db.server';
// (A1) NoteEditor.client.js istemci bileşenini import ediyoruz.
import NoteEditor from 'NoteEditor.client';
function Note(props) {
const {id, isEditing} = props;
// (B) Veritabanına direkt olarak erişim sağlayabilir.
const note = db.posts.get(id);
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{/* (A2) Dinamik olarak not editörü render ediliyor. */}
{isEditing
? <NoteEditor note={note} />
: null
}
</div>
);
}
Burada farkedileceği gibi:
- Note bileşeni bildiğimiz React bileşeni gibi çalışıyor. Her zamanki gibi props’tan veri çekiyor ve bir view render ediyor. Fakat bu benzerliğin aksine sunucu bileşenlerinin bazı kısıtlamaları da bulunuyor. Örneğin
useState()
veyauseEffect()
gibi stateful özellikler kullanamıyorlar. Çünkü sunucuya gelen her bir request karşılığında sadece bir kez çalışacakları için state barındıramıyorlar. Bunun haricinde diğer tüm kısımlar beklediğiniz gibi çalışıyor diyebiliriz. - Sunucu bileşenleri, (B)’de de görüldüğü şekilde veritabanı gibi sunucu kaynaklarına direkt olarak erişim sağlayabilirler. Burada db’den veri çekme işlemi genel bir mekanizma aracılığı ile kodlandığı için, komünite tarafından farklı veri kaynakları ile çalışabilecek API’lar yaratılabilir.
- Sunucu bileşenleri, sırasıyla (A1) ve (A2) de görüldüğü gibi bir istemci bileşenini import edebilir ve render ederek istemci tarafına iletebilir. İstemci bileşenleri
.client.js
,.client.jsx
veya.client.tsx
gibi dosya uzantıları ile oluşturulabilirler. Webpack gibi bundler’lar (paketleyici programlar) ise, bu import’ları aynı dinamik import’lar gibi yorumlayarak, farklı koşullarda farklı bundle dosyaları oluşacak şekilde ayırabilirler. Bu örnekte de olduğu gibi, yalnızcaprops.isEditing
değişkeni true değerini aldığındaNoteEditor.client.js
bileşeni istemciye gönderilecektir. Diğer durumlarda istemci tarafına iletilmeyecektir.
İstemci bileşenleri ise alışkın olduğunuz React bileşenleri gibi çalışır. State, effect, DOM’a erişim vb. şekilde React içerisinde yer alan özelliklerinin tamamına erişebilirler. “İstemci bileşeni” kavramı aslında yeni bir özellik değildir, sunucu bileşenleri ile ayrımı sağlayabilmek için bu ifade kullanılmaktadır.
Şimdi örneğimize devam edelim ve NoteEditor.client.js
bileşenini aşağıdaki gibi oluşturalım:
// NodeEditor.client.js - İstemci bileşemi
export default function NoteEditor(props) {
const note = props.note;
const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body);
const updateTitle = event => {
setTitle(event.target.value);
};
const updateBody = event => {
setTitle(event.target.value);
};
const submit = () => {
// Notun kaydedilmesi işlemleri...
};
return (
<form action="..." method="..." onSubmit={submit}>
<input name="title" onChange={updateTitle} value={title} />
<textarea name="body" onChange={updateBody}>{body}</textarea>
</form>
);
}
Not: Bu haliyle klasik React bileşenleri gibi görünmektedir. Çünkü istemci bileşenleri zaten alışkın olduğumuz React bileşenleridir.
Burada dikkat edilmesi gereken kısım ise, Sunucu bileşenlerinin render ettiği arayüz, istemci tarafına iletildiğinde, halihazırda render edilmiş istemci bileşenlerinin state’ini korunmaktadır. React, sunucudan gelen yeni props değerleri ile mevcut istemci bileşenlerini birleştirir ve böylece mevcut bileşenlerin focus, state veya devam eden animasyonlarının korunması sağlanır.
Motivasyon
Sunucu bileşenleri, React’le yazılan birçok uygulamada yaygın olarak görünen birtakım zorlukların çözümü için üretilmişlerdir. React ekibi bu zorlukların çözümü için piyasadaki mevcut çözümleri araştırdığında, genellikle bu problemlere daha basit çözümler ile karşılaştılar. Fakat bu çözümler, tatmin edici durumda değillerdi. Çünkü React uygulamalarının karşılaştığı temel problem, bu uygulamaların istemci-merkezli olması ve sunucunun sağladığı avantajlardan yararlanamamasıdır. Eğer sunucunun faydalarını sağlayabilecek şekilde kod yazılırsa, karşılaşılan bu problemler çözülecek, bu sayede ister küçük ister büyük ölçekli yazılımların geliştirimi için daha güçlü bir yaklaşım ortaya atılacaktır.
Karşılaşılan bu zorluklar iki temel kategoride incelenebilir. Bunlardan birincisi, geliştiricilerin varsayılan olarak yüksek performanslı bir şekilde uygulama oluşturmalarını sağlamaktır. İkincisi ise React uygulamalarında veri çekme işlemlerini daha kolay hale getirmektir. Daha önceden React yazdıysanız, buradaki yeteneklerden en az birkaçının olmasını istemişsinizdir:
Bundle boyutuna etkisi sıfır olan bileşenler
Geliştiriciler, uygulama geliştiriminde üçüncü parti kütüphaneler arasında seçim yapmak durumunda kalırlar. Örneğin, bir blog metnini markdown ile formatlamak için marked gibi kütüphaneler kullanışlı olsa da uygulama boyutunu arttırarak performansa olumsuz etki etmektedir. Kütüphane kullanmaksızın bu fonksiyonlar yazılmak istendiğinde ise, bu yaklaşım hem zaman alıcı olmakta, hem de üretilen kod daha fazla hataya meyilli hale gelmektedir. Tree-shaking gibi bazı gelişmiş özellikler bu konuda yardımcı olsa da, günün sonunda istemci tarafına gereksiz kod gönderimi kaçınılmaz hale gelmektedir. Buradaki bahsettiğimiz not uygulaması örneğinde de markdown bilgisi istemci tarafı üzerinde kurgulansaydı, 240K boyutunda JavaScript koduna ihtiyaç duyacaktı.
Tabii ki marked yerine daha küçük boyutlu kütüphaneler de kullanılabilir. Hatta sadece birkaç byte’lık bir kütüphane kullansanız dahi, kullanıcılar o kadarlık byte’ı indirmek durumunda kalacaktır. Buradaki örneğin amacı herhangi belirli bir kütüphanenin kullanımı değil, kütüphane kullanımının geliştiriciler için faydası olmasının yanında, bundle boyutunu arttırarak uygulama performansına zarar verebileceği ele alınmaktadır:
// NoteWithMarkdown.js
// NOT: Server komponentlerinden öncesi gösterilmektedir.
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* render */);
}
Ancak, uygulamaların birçok kısmı genellikle etkileşimli değildir ve tamamen bir veri tutarlılığının sağlanmasına da ihtiyacı yoktur. Örneğin detay sayfaları genellikle bir ürün, kullanıcı veya diğer varlıklar hakkında bilgi sunarken kullanıcı etkileşimine yönelik herhangi bir cevap vermesi gerekmeyebilir. Buradaki NoteDetail
bileşeni de düzenleme gerektirmeyen sayfalara güzel bir örnektir.
Sunucu bileşenleri, statik bileşenin sunucuda render edilmesine olanak sağlarken, aynı zamanda React’in bileşen-yönelimli modelinin avantajlarının da kullanılmasına yardımcı olur. Ayrıca statik render edilen bileşenlerin bundle boyutuna etkisi sıfır olacağı için performanstan da öndün verilmemiş bir geliştirim ortamı oluşturulur.
Eğer üstteki NoteWithMarkdown
bileşenini sunucu bileşenine dönüştürürsek, markdown özelliklerini olduğu gibi kullanabilir ve aynı zamanda kullanıcıya iletilmesini engelleyerek veri trafiğinden kazanç sağlayabiliriz. Gzip ile sıkıştırılmamış haldeki kodun boyut kazancı 240K’nın üstünde kadar çıkacaktır:
// NoteWithMarkdown.server.js
// NOT: Sunucu bileşeni olduğu için boyuta etkisi sıfırdır.
import marked from 'marked'; // 0 Byte
import sanitizeHtml from 'sanitize-html'; // 0 Byte
function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* render */);
}
Backend’e tam erişimin sağlanması
React uygulamaları yazılırken en yaygın olarak karşılaşılan zorluklardan biri ise veriye nasıl erişim sağlanacağı ve bu verinin nerede saklanacağıdır. React ile veri çekme işlemleri için birçok yöntem bulunduğu gibi, verinin saklanması için de biçok farklı veritabanı bulunmaktadır. Ancak bu yaklaşımlar, bazı zorlukları da beraberinde getirmektedir. Genel bir kullanım olarak, arayüzü veri ile canlandırmak için geliştiriciler bir takım ek servis endpoint’lerine ihtiyaç duymaktadırlar. Bazen arayüze birebir uymayan mevcut endpoint’lerin de React tarafında uyarlanması gerekebilir. React ekibi bu probleme ışık tutmak amacıyla, hem yeni başlayan React geliştiricileri için kolay hale getirme, hem de büyük uygulamalarda veri karmaşıklığını yönetme için bir çözüm geliştirdiler.
Bu çözüm sayesinde aşağıdaki gibi yeni bir uygulama oluşturduğunuzda, veriyi nerede tutacağınıza karar veremiyorsanız, dosya sisteminde basit bir şekilde verilerinizi barındırabilirsiniz:
// Note.server.js - Sunucu bileşeni
import fs from 'react-fs';
function Note({id}) {
const note = JSON.parse(fs.readFile(`${id}.json`));
return <NoteWithMarkdown note={note} />;
}
Daha gelişmiş uygulamalarda, veritabanlari, internal (mikro)servisler, ve diğer veri kaynakları kulanılabilir.
// Note.server.js - Sunucu bileşeni
import db from 'db.server';
function Note({id}) {
const note = db.notes.get(id);
return <NoteWithMarkdown note={note} />;
}
Otomatik kod ayırma (code splitting)
Eğer React ile bir süredir çalışıyorsanız code splitting konseptini biliyor olabilirsiniz. Bilmeyenler için yardımcı olmak gerekirse code splitting, uygulamanın küçük bundle’lar halinde bölünmesiyle istemciye daha az kodun iletilmesini sağlamaktadır.
Code splitting için yaygın yaklaşımlardan biri, her bir route bazında bundle yükleme veya runtime’da değişecek bir kritere göre farklı modüllerin lazy loading (geç yükleme) olarak yüklenmesidir. Örneğin uygulamalar, kullanıcı bazında, içerik bazında, ve bazı feature flag’ler bazında kodun bir takım kısımlarını lazy loading yöntemi ile yükleyebilir:
// PhotoRenderer.js
// NOT: Sunucu bileşenleri öncesi
import React from 'react';
// Bu bileşenlerden biri, bir defa yüklenecek ve istemciye stream edilecektir:
const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));
function Photo(props) {
// Feature flag'e göre render işlemi. Örn login/out, içerik tipi vb...
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <OldPhotoRenderer {...props} />;
}
}
Code splitting işlemi, performansı arttırmak için oldukça yararlı olabilir. Fakat mevcut code-splitting tekniklerinde iki temel kısıtlama bulunmaktadır. Bunlardan birincisi, geliştiriciler bu yöntemi nasıl uygulayacaklarını bilmeli ve bu bağlamda da klasik import ifadelerini React.lazy
ifadeleriyle değiştirmeleri gereklidir. Bu yaklaşımdaki ikinci dezavantaj ise, çalışma anında uygulama hangi bileşeni yüklemesi gerektiğini seçeceği için yükleme esnasında gecikme yaratmaktadır.
Sunucu bileşenleri bu kısıtlamaları iki şekilde çözmektedir. Bunlardan birincisi, code splitting işlemi otomatik hale getirilmektedir. Çünkü sunucu bileşenleri, istemci bileşenlerinin import edildiği tüm ifadeleri potansiyel bir code splitting noktası olarak ele alarak dosya ismi bazında otomatize etmektedir. İkincisi ise, sunucu bileşenleri sayesinde geliştiricinin hangi bileşeni en önce yükleneceğini belirlemesine olanak tanımasıdır. Böylece istemci, rendering işleminde o bileşeni daha önce indirerek kullanıma hazır hale getirebilir.
Sunucu bileşenlerinin en önemli avantajı, geliştiricilerin uygulama koduna odaklanmasını sağlar, geri kalan kısımların framework tarafından otomatik olarak optimize edilmesine olanak tanımasıdır.
// PhotoRenderer.server.js - Server Komponenti
import React from 'react';
// Bu bileşenlerden biri, bir defa yüklenecek ve istemciye stream edilecektir:
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
import NewPhotoRenderer from './NewPhotoRenderer.client.js';
function Photo(props) {
// Feature flag'e göre render işlemi. Örn login/out, içerik tipi vb...
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <OldPhotoRenderer {...props} />;
}
}
İstemci-sunucu waterfall’unun engellenmesi
Uygulamanın performansının kötü olmasındaki yaygın nedenlerden biri ise, arka arkaya (sequential olarak) veri çekme isteğinin gönderilmesinden kaynaklanmaktadır. Yaygın olarak kullanılan yöntemlerden biri, başlangıçta placeholder olacak bir arayüz render edilerek, daha sonra useEffect()
hook’u ile verinin çekilmesi işlemidir.
// Note.js
// NOT: Sunucu bileşenlerinden önce
function Note(props) {
const [note, setNote] = useState(null);
useEffect(() => {
// NOT: render işleminden sonra gerçekleşir ve child bileşenlerde waterfall'u tetikler
fetchNote(props.id).then(noteData => {
setNote(noteData);
});
}, [props.id]);
if (note == null) {
return "Loading";
} else {
return (/* Note'un render edilmesi... */);
}
}
Parent ve child bileşen bu yaklaşımı kullandığında, child bileşen öncelikle kendi verisini yüklemek için parent bileşenin kendi verilerini yüklemesini bekler. Bu yaklaşım ne kadar dezavantajlı gibi görünse de bazı pozitif yönleri de vardır. Bunlardan biri ise, yalnızca render edilecek olan bileşenin verilerinin getirilmesidir. Bu sayede henüz render edilmeyen bileşen için veri çekme işlemi yapılmadan daha az kaynak tüketen ve daha performanslı bir uygulama oluşur. Bu noktada, hem art arda (sequential) olarak veri getirimi işleminden kaçınmalı, hem de kullanılmayacak kısımlar için veri getirimi yapmamak için bir yol bulmamız gereklidir.
Sunucu bileşenkeri bu problemdeki art arda veri getirme işlemini sunucu tarafında çözmektedir. Bu sayede istemci-sunucu arası bekleme süresi azalmış olur ve performans artar. Buna ek olarak, sunucu bileşenleri ile sadece gereken bileşen render edilerek sadece minimal veri aktarımı da sağlanmış olur.
// Note.server.js - Sunucu bileşeni
function Note(props) {
// NOT: Sunucudaki düşük gecikmeli veri erişimi ile render aşamasında veriyi yükler
const note = db.notes.get(props.id);
if (note == null) {
return "Loading";
} else {
return (/* Note'un render edilmesi... */);
}
}
Aslında buradaki verilerin waterfall olarak çekilmesinin sunucuda yapılması da en ideal bir yöntem değildir. Bu nedenle React ekibi, optimizasyon için veri isteklerinin önce yüklenmesi ile ilgili bir API sağlayacaktır.
Soyutlama maliyetinden kurtulma
React’in herhangi bir şablon dili kullanması yerine JavaScript kullanmasının avantajlarından biri de, fonksiyon kompozisyonu gibi JS’ten gelen dil özelliklerini kullanarak güçlü arayüz soyutlamalarını gerçekleştirmesidir. Ancak bu soyutlamaların aşırı kullanımı, daha fazla koda ve daha fazla runtime’daki karışıklığa yol açmaktadır. Statik dillerdeki UI framework’leri, ahead-of-time (runtime öncesinden) derleme işlemini kullanarak bu problemlerin üstesinden gelmektedir. Fakat bu seçenek JavaScript’te geçerli değildir.
Sunucu bileşemleri bu problemin çözümü için soyutlama maliyetini sunucu üzerinde gidermektedir. Örneğin iç içe birçok wrapperdan oluşan bir sunucu bileşeni varsa, bu bileşen tek bir HTML elemanı olarak render edilir ve sadece bu eleman istemciye iletilir. Aşağıdaki örnekte Note
bileşeni, <div>
ile sarmalanmış NoteWithMarkdown
adında ara bir soyutlama bileşeni içermektedir. Render anında React sadece bu div’i ve barındırdığı içerikleri istemciye iletir.
// Note.server.js
// ...import ifadeleri...
function Note({id}) {
const note = db.notes.get(id);
return <NoteWithMarkdown note={note} />;
}
// NoteWithMarkdown.server.js
// ...import ifadeleri...
function NoteWithMarkdown({note}) {
const html = sanitizeHtml(marked(note.text));
return <div ... />;
}
// İstemci tarafın gördüğü
<div>
<!-- markdown output here -->
</div>
Ayrı problemler, tek çözüm
Üstte de bahsedildiği gibi bütün bu zorlukların ana nedeni React’in temel olarak istemci-merkezli bir kütüphane olmasından kaynaklanıyor. PHP, Rails gibi geleneksel server rendering dilleri bu zorlukların birkaçı için tek çözüm yolu olabilir. Ama bu yöntemler de zengin ve etkileşimli uygulamalar yapmayı zorlaştırmaktadır. Aslında bu durumun kendisi de web geliştirme dünyasındaki bitmek bilmeyen gerginliği yansıtmaktadır: istemci uygulamaları çok yetenekli mi olmalı, yoksa “aptal” bıraklılarak bileşenler sunucuda mı render edilmeli?
Sunucu bileşenleri sayesinde React uygulamaları, tek dil ve tek framework ile geliştirilirken, aynı zamanda server side rendering ve client side rendering’in avantajlarının ikisinden de yararlanmaktadır.
Sonuç olarak
Sunucu bileşenleri, hem istemci hem de sunucu tarafında uygulama geliştirimi sağlayarak pek çok farklı avantaj sunuyor. Belki de zaman içerisinde React geliştiricileri Fullstack developer olarak anılmaya başlayacak. Kim bilir:)
Sonraki yazımda görüşmek üzere. Hoşça kalın…