Знайомство з Kotlin/JS — поєднуємо солодке та гірке
Ця стаття є частиною циклу «Монструозний Kotlin». Ось перша і друга частини. У цій статті ми трішки допишемо Cat Facts SDK, навчимося його тестувати і познайомимося з Kotlin/JS, а саме: напишемо браузерний застосунок з використанням React та нашого SDK (попереджаю — я не вчив React, увесь код, пов’язаний з React, показаний лише в цілях демонстрації та згенерований ChatGPT). Так, це мізер, але паралельно ви дізнаєтесь про всі необхідні нюанси: як налаштувати Karma та Mocha у Kotlin/JS та мультиплатформенних бібліотеках, як використовувати і мультиплатформенні, і npm-залежності через Gradle, а може, і використаєте отримані знання на практиці вже сьогодні. У попередній частині ми з вами перетворювали Cat Facts SDK у мультиплатформенну бібліотеку, мемно понаступали на усі граблі та навчились запускати його через Kotlin/Native на Linux (до речі, ви вже додали підтримку Windows та macOS? Якщо так і на вінді, то співчуваю, MinGW це справжня ср*ка-ґузиця. З нетерпінням чекаю посилання на ваші форки у коментарях, на найцікавіші дам фідбек), а ця стаття вже буде цікава фронтендерам.
Головне у використанні зброї — правильно прицілитись
І тому я швиденько накидав цікавий недо-DSL, котрий дозволяє конфігурувати base url та довжину фактів за замовчуванням. Він виглядає ось так:
val sdk = CatFacts {
// отут-во можна конфігурувати
baseUrl = "https://any.domain.that.implements.api.com"
defaultFactLength = 50
}
Код дуже простий, щоб отримати щось подібне треба додати у конструктор extension-лямбду:
public class CatFacts(configLambda: CatFactsConfig.() -> Unit = {}) {
...
}
...та створити конфіг-холдер, клас, котрий буде тримати наші конфіги:
public class CatFactsConfig {
public var defaultFactLength: Int? = null
public var baseUrl: String = "https://catfact.ninja"
}
Далі виставити дефолтні аргументи для властивостей всередині класу:
private var config = CatFactsConfig().apply(configLambda) private val client = getClient(Url(config.baseUrl)) // ... public suspend fun getCatFact(maxLength: Int? = config.defaultFactLength) // ...
Автоматизуємо дегустацію
QA без хліба не залишаться, особливо ті, хто використовує Kotlin згідно з нещодавньою зарплатною аналітикою DOU — у них медіана $3,3K. Щоб додати можливість тестування треба, просто додати залежності у сорс-сет commonTest:
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-annotations-common"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2")
}
}
...та додати корутини у commonMain (тестувати будемо за їх допомогою):
val commonMain by getting {
dependencies {
...
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2")
}
}
Синкуємо та біжимо створювати папку commonTest/kotlin у src. Усередині створимо клас SDKTest. Але що ж ми будемо тестувати? Давайте для початку напишемо тест, що створить SDK та напише нам факт:
class SDKTest {
@Test
fun testCreateSDKWithDefaultConfig() = runTest {
val sdk = CatFacts()
sdk.getCatFact()
}
}
У Gradle буде цікавий task з назвою allTests. Запустімо... Іііі... JVM-тести впадуть на шпагат під тиском часу:
After waiting for 10s, the test coroutine is not completing
Упс, я вперся у тайм-аут, таке буває, коли поганий інтернет. Давайте це виправимо...
@Test
fun testCreateSDKWithDefaultConfig() = runTest(timeout = 30.seconds) { // додаємо параметр тут
...
}
Якщо у вас так само впали тести через тайм-аут, вони у вас все одно падатимуть, щоправда, тільки JS-тести. Щоб це виправити для браузерних тестів треба створити папку karma.config.d та залити туди js-файл, у якому отаке:
config.set({
client: {
mocha: {
timeout: 30000
}
}
});
Як назвете файл — неважливо. Я от, наприклад, назву його putin-xuilo.js. Взагалі, всередині ви можете створити нескінченну кількість конфігів — усі вони будуть злиті в один файл компілятором Kotlin/JS. А щоб виправити це у Node-тестах треба піти до біса у build.gradle.kts (а різниця між бісом та Gradle?) і там, де ми декларуємо підтримку JS, додати таку лямбду:
js(IR) {
browser()
nodejs {
testTask(Action {
useMocha {
timeout = "20s"
}
})
}
}
Але... У мене Chrome у Flatpak — це контейнерна фігня та все таке, коротше, хром «у в’язниці» якомога далі від основної системи за злиття даних гуглу, а Karma із Flatpak-браузерами не сильно хоче працювати, тому браузерні тести в мене впадуть, а ось нода — ні. Щоб змусити тести працювати, мені треба встановити Firefox або будь-який інший браузер через пакетний менеджер (я на лінуксі, і у Windows все має працювати й так) та зареєструвати його у Gradle ось так:
js(IR) {
browser {
testTask(Action {
useKarma {
useFirefox() // отут-во
}
})
}
nodejs {
...
}
}
Тепер нумо запускати тест — хай тортик продегустується:
./gradlew allTests
Тортик смачний, офіційно.

Уважні помітять ще один інгредієнт у тесті (тісті?) — createConfigurableSDK(). Цей тест я написав, доки сумував, бо очікувати на мобільний інтернет у селі — така собі справа. Навіть тимчасово таймаути у 5 хвилин усюди викрутив (у коді на GitHub так і залишились звичайні таймаути, що писались тут). Ось його код:
@Test
fun createConfigurableSDK() = runTest(timeout = 5.minutes) { // на гітхабі досі 30 сек
val sdk = CatFacts {
defaultFactLength = 50
}
repeat(5) {
val fact = sdk.getCatFact()
assertTrue(fact.fact.length <= 50)
}
}
Кайф. Тепер торту можна обрати максимальну довжину та смак у вигляді base url :)
Експортуємо солодкий SDK у гіркий JS
Настав час скористатись анотацією @JsExport — з її допомогою ми поставимо мітку на тому, що ми хочемо, щоб це було видимим у JS. Але просто взяти і помітити нею увесь код не вийде, бо на цей час вона в альфі, і сильно обмежена (навіть шоколадну посипку не всюди можеш посипати!). Можна експортувати лише те, що повертає/ приймає прості типи, або класи, що також помічені як експортні, та не має у собі suspend. Так, у серйозний форум щойно була принесена альфа-технологія, бійтеся солодко-радіоактивного зараження! І давайте так само серйозно покумекаємо, що ж нам із цим робити. Можна написати platform-friendly обгортку над SDK у jsMain, а можна зробити окремий адаптер (корисно для більших проєктів). Я піду першим шляхом. Класи Fact та CatFactsConfig можна просто анотувати, бо там нічого окрім стрингу та інту і нема:
@OptIn(ExperimentalJsExport::class)
@JsExport
@Serializable
public data class Fact(val fact: String)
@OptIn(ExperimentalJsExport::class)
@JsExport
public class CatFactsConfig {
public var defaultFactLength: Int? = null
public var baseUrl: String = "https://catfact.ninja"
}
Тепер можна писати і обгортку над CatFacts, для цього задля зручності додамо вторинний конструктор у SDK:
public class CatFacts(config: CatFactsConfig.() -> Unit = {}) {
private val client = getClient()
private var config = CatFactsConfig().apply(config)
public constructor(config: CatFactsConfig) : this() {
this.config = config
}
...
}
Тепер використаємо новий конструктор у класі-обгортці:
@OptIn(ExperimentalJsExport::class)
@JsExport
public class CatFactsJs(config: CatFactsJsConfig.() -> Unit = {}) {
private val config = CatFactsJsConfig().apply(config).toCommonConfig()
private val sdk = CatFacts(this.config)
@OptIn(DelicateCoroutinesApi::class)
public fun getCatFact(maxLength: Int? = config.defaultFactLength): Promise = GlobalScope.promise {
sdk.getCatFact(maxLength)
}
}
Зараз знавці корутин мене закидають помідорами або обіллють липкою шипучкою, бо я використав GlobalScope, від використання якого застерігає документація та хмара опт-інів, але у конкретно даному випадку ми просто «промапили» цю функцію з Coroutine-середовища у JS, бо тепер контроль над потоком, де ця функція запускається, вже у JS на руках, ми її трансформували у так званий Promise. Це дозволить нам запускати її як звичайну async-функцію, і вести вона себе буде поза структурованими корутинами, але за правилами JS.
Пишемо сам вебзастосунок чи ні?
Окей, SDK солодкий, конфігурабельний, гнучкий, митницю пройде, бо експортабельний. Тепер можна і у JS його засунути. Кому цікаво — ось плагін для публікації. Kotlin/JS-бібліотек у npm — для тих, хто хоче написати на Kotlin/JS щось самотужки. Також пакет з SDK лежить у npm, я за вас усе опублікував, і інструкції як вам зробити так само не буде... Жартую, звісно що буде! У build.gradle.kts додаємо цей плагін:
plugins {
...
id("dev.petuska.npm.publish") version "3.4.1"
}
...міняємо конфігурацію JS-компілятора, щоб він видавав бібліотеки:
js(IR) {
browser {
...
}
nodejs {
...
}
useCommonJs() // для сумісності з нодою
binaries.library() // тепер він видаватиме нам бібліотеку
}
...та конфігуруємо саму публікацію:
npmPublish {
registries {
register("npmjs") {
uri.set("https://registry.npmjs.org")
authToken.set("npm_ваш_токен_тут")
}
}
}
Щоб опублікувати щось у npm, треба отримати токен, за мене багато разів розповідали, як це зробити, але мені допомогла документація. Після збиття gradle sync-блендером у нас з’явиться таска publishJsPackageToNpmjsRegistry. НЕ запускаємо її, бо вона все одно впаде, бо такий самий пакет вже є.
Тепер точно пишемо
Окей, у npm наш SDK вже є, тепер прийшов час для чистого та гіркого JS, у якому і я не сильно розбираюсь. Створюємо новий React-застосунок, додаємо у package.json залежність на SDK:
{
...
"dependencies": {
...
"catfacts-sdk": "^1.5.2"
},
...
}
Тепер заходимо у App.js, імпортуємо наш SDK:
import module from "catfacts-sdk"
По дефолту усі бібліотеки пакуються вебпаком у модуль, названий module. Змінити це ім’я можна у build.gradle.kts, ось так:
kotlin {
js(IR) {
moduleName = "catfactsSdk"
// ...
}
}
Тепер можна імпортувати не module, а catfactsSdk:
import catfactsSdk from "catfacts-sdk"
Тепер, знаючи ці нюанси, ми можемо нарешті написати щось, що буде нарешті виводити їх на екран поза консоллю. Відредагуємо function App() таким чином:
const sdk = new catfactsSdk.com.bpavuk.catfacts.CatFactsJs()
function App() {const [fact, setFact] = useState(null);
useEffect(() => { // щось на кшталт LaunchEffect із Composesdk.getCatFact()
.then((res) => { // оброблюємо проміс та отримуємо клас FactsetFact(res.fact); // дістаємо факт (пам'ятаєте те поле датакласу Fact?)
})
.catch((error) => { console.error('Error fetching cat fact:', error); // про всяк випадок});
}, []); // Run this effect once on component mount
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" /><p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
{fact ? <p>Your random cat fact is: {fact}</p> : <p>Loading cat fact...</p>}</header>
</div>
);
}
export default App;
Ну що ж, ви пригадуйте «Отче Наш» (а мені не треба, я атеїст), і запускаємо:
І знову сумний факт попався, хоч і корисний! Скільки можна :)) Офтоп: у Gnome, напевне, скоро з’явиться новий індикатор робочих просторів. Я вже встановив концепт-плагін, він заміняє напис Activities на такий приємний індикатор угорі. Ще й з анімаціями! Тепер пояснюю, що тут за чортівня:
- Ми створюємо SDK, але через гру джаваскрипту у модулі треба використовувати fully-qualified names, як тут:
catfactsSdk.com.bpavuk.catfacts.CatFactsJs(). Цю проблему можна вирішити, порушивши структуру самої бібліотеки — перемістити обгорткуCatFactsJs()у корінь сорс-сетуJsMain. Тоді вийде простоcatfactsSdk.CatFactsJs(). - Далі створюємо стейт та сетер для нього:
const [fact, setFact] = useState(null);, ініціюємо нулем. - Потім, у
useEffectми передаємо лямбду, де виконується єдина функція нашого SDK та є базовий хендлінг помилок. - Далі ми повертаємо JSX HTML (сам не зрозумів, що це, навіть ChatGPT не зміг пояснити), у котрому прописаний контент сторінки — символіка
нацистівReact та наш невеличкий факт.
І знову повторюсь для тих, у кого шок, — ми написали логіку на Kotlin та використали її у JavaScript. А тепер ще більший шок — Compose є і для вебу, але це вже тема для четвертої частини статті.
І висновки
Ми зачепили декілька цікавих патернів і тем, навчились користуватись trailing lambda syntax, експортувати самописні бібліотеки у JS, «смикати» їх звідси та конфігурувати платформи для тестування під JS. У наступній, завершальній, частині ми вже будемо користуватись Compose for Web та Tailwind CSS, там ми зачепимо використання npm та бібліотек звідси у Gradle та Kotlin/JS (жесть, я стільки разів сказав «JS», хоч би джаваскриптизером не стати). Ця частина вже буде найцікавішою та найменш проблемною, андроїдщикам буде дуже цікаво, бо всі ми любимо Compose. До речі, мені 14, а JavaScript став моєю першою мовою програмування у 8 років :)
Для тих, кому мало солодкого
Тут я наведу посилання на усе, що вам може знадобитись у Kotlin/JS:
- Compose Multiplatform — якби ви були андроїдманом, ви б вже знепритомніли від щастя та передозу синтаксичного цукру.
- Kotlin Wasm — потенційно менш проблемна (але експериментальна) заміна Kotlin/JS.
- Увесь код, що ми писали — SDK, React-застосунок.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів