Подход для удобного и быстрого написания модульного масштабируемого фронтенда для приложений любого размера.
Примеры на React + TypeScript, НО вы можете использовать абсолютно что угодно, на любой языке, платформе и точке временного континуума.
WSD нативно повторяет любую систему, которую можно описать как: “Древовидный UI с возможностью манипулировать им функциями и работать с переменными” – прям вот в такой тупой формулировке, засчет чего работал, работает и будет работать.
От автора
Привет, дорогой читатель, меня зовут Давид Шекунц и я Tech Lead с 12-летним опытом.
WSD – это одно из лучших открытий в моей карьере программиста. Оно настолько логично повторяет то, как устроен фронтенд, что идеально ложится под любые задачи.
Я использую этот подход уже лет 7 и ни разу не было кейса, когда он создавал бы мне проблемы или подводил.
Звучит как магия / серебряная пуля? Буду глупцом, но черт возьми, скажу “да”, удивительно, но пока я не нашел кейса, где эта структура бы не работала.
И теперь, жорогой читатель, я вызываю тебя на дуэль: найди такой кейс, покажи мне его и сделай самое важное в истории человечества – уничтожь первую и последнюю серебряную пулю.
Подписывайтесь на тг канал [ $davids.sh ] , где будут выходить анонсы новых глав и обновления существующих
Примеры приложений
Приложения, которые написано по WSD:
(coming soon)
Словарь
Entity (Сущность)– описание каких-то данных, бывает 3-х видов:UI (Интерфейсная)– конкретного UI компонента: виден ли, что написано в input, etc. (часто 80% всего кода)Util (Утилитарная)– геометрия (Точка в пространстве, Треугольник), внешний API (Stripe, ), то есть все то, что предопределено внешним для нас миром.Business (Бизнес)– состояние бизнес-сущностей, с которыми мы работает. Очень часто оно приходит к нам и попадает обратно в БД на backend.
// Enitity описываются через какую-то типизацию, будь то type, interface или class // Пример, Интерфейсной Сущности type LoginPopup = { closed: boolean collapsed: boolean loading: boolean error: string } // Пример, Утилитарной Сущности Треугольник type Point = { x: number; y: number; } type Triangle = { one: Point; two: Point; three: Point; } // Пример, Утилитарной Сущности Stripe API type StripeCreateInvoceRequest = { id: string total: number } type StripeCreateInvoceResponse = { success: boolean message: string } // Пример, Бизнес-сущности type Product = { name: string price: number comments: Comment[] } type Cart = { products: Product[] totalSum: number }
State (Состояние)– та или иная комбинация Сущностей в том или ином Компоненте на конкретный момент времени.
// loginPopup – это уже Состояние на базе UI сущности LoginPopup const loginPopup = useState<LoginPopup>({ ... }) // Состояние утилитарного кода треугольника const pointOne = useState<Point>({ x: 10, y: 10 }) // ... const triangle = useState<Triangle>({ one: pointOne, ... }) // Состояние бизнес-сущности Корзина const cart = useState<Cart>({ ... })
Логика– набор функций, которые что-то делают с Cостоянием, обычно выделяют 3 категории:UI (Интерфейсная)– состояние конкретного UI компонента: виден ли, что написано в input, etc. (часто 80% всего кода)Util (Утилитарная)– например, функции расчета см в м, или гипотенузы через косинус, или доллара в рубли, то есть все то, что предопределено внешним для нас миром (физикой, законами, математикой, etc.)Business (Бизнес)– это функции, которые работают с Бизнес-сущностями вашего конкретного приложения (Положить Товар в Корзину,Удалить Комментарий), часто подразумевает работу с backend
// UI-логика — работает с состоянием интерфейса const openLoginPopup = (popup: LoginPopup): LoginPopup => ({ ...popup, closed: false, }) const closeLoginPopup = (popup: LoginPopup): LoginPopup => ({ ...popup, closed: true, error: "", }) const setLoginError = ( popup: LoginPopup, error: string, ): LoginPopup => ({ ...popup, error, loading: false, }) // Util-логика — не знает ничего про ваше приложение const getDistance = (a: Point, b: Point): number => { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) } const getTrianglePerimeter = (triangle: Triangle): number => { return ( getDistance(triangle.one, triangle.two) + getDistance(triangle.two, triangle.three) + getDistance(triangle.three, triangle.one) ) } // Business-логика — работает с бизнес-сущностями приложения const getCartTotalSum = (cart: Cart): number => { return cart.products.reduce((sum, product) => { return sum + product.price }, 0) } const addProductToCart = ( cart: Cart, product: Product, ): Cart => { const products = [...cart.products, product] return { ...cart, products, totalSum: getCartTotalSum({ ...cart, products }), } } const removeProductFromCart = ( cart: Cart, productName: string, ): Cart => { const products = cart.products.filter(product => { return product.name !== productName }) return { ...cart, products, totalSum: getCartTotalSum({ ...cart, products }), } }
Component (Компонента)– UI компонент с любыми из вариантов и комбинаций логик:
type CartWidgetProps = { user: User | null cart: Cart openAuthPopup: () => void } const CartWidget = ({ user, cart, openAuthPopup, }: CartWidgetProps) => { const [loading, setLoading] = useState(false) const [error, setError] = useState("") const pay = async () => { if (!user) { openAuthPopup() return } setLoading(true) setError("") const response = await createStripeInvoice({ id: user.id, total: cart.totalSum, }) setLoading(false) if (!response.success) { setError(response.message) return } window.location.href = response.paymentUrl } return ( <div> <div>Products: {cart.products.length}</div> <div>Total: {cart.totalSum}</div> {error && <div>{error}</div>} <button disabled={loading} onClick={pay}> {loading ? "Loading..." : "Pay"} </button> </div> ) }
Делим приложение на 5 частей
pages– Компонента с Состоянием и Бизнес-логикой, которые открываются по какому-то URL (можно также сказать: “widgetдля URL”)
widgets– Компонента с Состоянием и Бизнес-логикой, которые переиспользуются на разныхpages
apps– благодаря которым мы можем из разных наборовpagesсобирать разные приложения, например, под разные url, разные роли, разные env (например, веб и electron.js)
libs– код, как библиотеки, которые могли точно также лежать в npm, но у вас есть причины хранить их локально
Правила структурирования
А вот тут появляется очень важная концепция – “Уровень”.
Одна из гигантских проблем фронтенд – как структурировать код так, чтобы переиспользуемый код был удобно доступен и понятен там, где он переиспользуется, а там где он не нужен, его бы не было.
Можно сказать так: “Я хочу иметь возможность удалить 1 папку и вместе с ней полностью уничтожить целую функцию” – или: “Я хочу перенести 1 папку из проекта в другой проект и получить полностью рабочий функционал”.
Вот для реализации этой модульности нам и нужны Уровни – правила структурирования приложения, которые указывают на то, что с чем может взаимодействовать.
src/ ├── apps/ # точки сборки приложений │ └── shop/ │ └── index.tsx │ ├── pages/ # страницы, привязанные к URL │ ├── home/ │ │ └── index.tsx │ └── cart/ │ └── index.tsx │ ├── widgets/ # глобальные переиспользуемые виджеты │ ├── auth-widget/ │ │ └── index.tsx │ └── cart-widget/ │ └── index.tsx │ └── libs/ # локальные библиотеки ├── ui-kit/ │ └── index.ts ├── auth-sdk/ │ └── index.ts └── math-fns/ └── index.ts
Вот правила по которым строится взаимодействие Уровней:
# Может казаться сложным, но это быстро понимается интуитивно, буквально смотрите # на вложенность папок друг в друга (вот прмя на табы и все становится понятно) src/ ├── apps/ # не рассматриваем, потому что их переиспользовать нельзя, он – самая высокая точка всей иерархии │ ├── libs-1/ # 0 уровень (корневой) │ ├── widgets/ │ └── widget-1/ # 0 уровень (корневой), родитель widget-2 │ ├── libs-2/ # 1 уровень, потомок widget-1 │ └── widget-2/ # 1 уровень, потомок widget-1 │ └── pages/ └── page-1/ # 0 уровень (корневой), родитель widget-3, page-2 ├── libs-3/ # 1 уровень, потомок page-1, родственник page-2, сородичь widget-2, libs-2 ├── widget-3/ # 1 уровень, потомок page-1, родственник page-2, сородичь widget-2, libs-2 └── pages/ └── page-2/ # 1 уровень, потомок page-1, родственник widgets-3, libs-3, сородичь widget-2, libs-2, родитель widgets-4 └── widget-4/ # 2 уровень, потомок page-2
- Уровень Х – это папка конкретного
widgets/pages/libsи каждая вложенная папка увеличивает значение Х - Потомок – вложенные
widgets/pages/libs - Родитель – папка, в которую вложена текущая
- Родственник – папки, которая находится прямо рядом
- Сородич – папка, которая находится в других
widgets/pages/libsна том же уровне вложенности, что и наш текущий (и сюда же пойдут все правила к его потомкам)
- Корневой Уровень (уровень 0) – там, где лежат наши самые верхнеуровневые
apps,pages,widgets,libs
- Что во что можно класть
libsможно класть в любую из папокwidgetsможно класть в любую из папок, даже в другиеwidgetspagesможно класть только вpagesappsмогут быть только на корневом уровне
- Правила переиспользования
- Ничто не может переиспользовать Сородичей – если что-то находится в сторонней ветке, то его нельзя трогать
- Все имеют право использовать
widgetsродителей, потомков и родственников - Все имеют право использовать
libsродителей и родственников libsне имеет право использовать никого, кроме другихlibsв родительских уровняхpagesимеют право использоватьpagesтолько вложенных в них, не Родительские и не Родственников
Дополнительно
- По факту
widget/pageможет состоять просто изindex.tsфайла, в котором будет и Логика, и UI
- Если у вас всего 1 app, можете просто сделать
index.tsв корнеsrcи не создавать папку
- Если хочется создать
widgetу которого не будет Компонента, значит, это надо создать библиотеку вlibs
- Не надо создать папки типа
widget/page, если вы их не используете
Private namespace
Если бы вы выкладывали библиотеку, например, с типами API или целым SDK до вашей системы, как бы она лежала в npm? Скорее всего
@${company}/some-sdk Как я говорил выше,
libs – это буквально npm. Соответственно внутри libs стоит создать папку @${company} и добавлять туда библиотеки конкретно про ваше приложениеsrc/ └── libs/ └── @company/ # приватные библиотеки проекта ├── ui-kit/ # дизайн-система │ └── index.ts ├── backend-sdk/ # клиент backend API │ └── index.ts └── analytics-sdk/ # клиент аналитики └── index.ts
Без Уровней
Если вам непонятны Уровни или вы пока не уверены, что и где должно лежать, тогда все супер просто: просто кладите Виджеты в верхнеуровневый
widgets, Страницы в верхнеуровневые pages , Библиотеки просто в libs# Вот все прям сюда: src/ ├── apps/ │ ├── app-1/ │ └── app-2/ │ ├── pages/ │ ├── page-1/ │ └── page-2/ │ ├── widgets/ │ ├── widget-1/ │ └── widget-2/ │ └── libs/ ├── lib-1/ └── lib-2/
Дополнительно
tests
Тесты точно также могут жить на любом уровне, а важно, рядом с тем местом, которое мы тестируем (опять же, мы пытаемся “с переносом папки перенести все связанное”, поэтому тесты должны лежать рядом с тем, что нужно)
src/ ├── apps/ │ └── shop/ │ ├── index.tsx │ └── tests.ts # тест сборки приложения │ ├── tests/ │ └── auth-flow.ts # глобальный e2e-тест │ ├── libs/ │ └── @company/ │ └── backend-sdk/ │ ├── index.ts │ └── tests.ts # тест SDK │ ├── widgets/ │ └── auth-widget/ │ ├── index.tsx │ └── tests.tsx # тест виджета │ └── pages/ └── home/ ├── index.tsx ├── tests.tsx # тест страницы ├── libs/ │ └── home-api/ │ └── index.ts └── widgets/ └── cart-widget/ ├── index.tsx └── tests.tsx # тест локального виджета
Итоговая структура
Условный интернет магазин:
src/ ├── apps/ │ ├── shop/ # приложение магазина │ │ └── index.tsx │ └── admin-panel/ # приложение админки │ └── index.tsx │ ├── tests/ │ └── auth-flow.ts # глобальный e2e-тест авторизации │ ├── libs/ │ ├── @company/ │ │ └── backend-sdk/ # SDK backend API │ │ ├── index.ts │ │ └── tests.ts │ └── stripe/ # локальная обёртка над Stripe │ └── index.ts │ ├── widgets/ │ └── auth-widget/ # глобальный виджет авторизации │ ├── index.tsx │ └── tests.tsx │ └── pages/ └── home/ # страница / ├── index.tsx ├── tests.tsx ├── libs/ │ └── home-api/ │ └── index.ts ├── widgets/ │ └── cart-widget/ # локальный виджет корзины │ ├── index.tsx │ └── tests.tsx └── pages/ └── product/ # страница /product/:productId ├── index.tsx └── widgets/ └── product-description/ └── index.tsx
Похожие концепции
- FALSe – структура приложений, которая описывает подобный подход, но для backend приложений.
- Feature-Sliced Design — тоже пытается структурировать frontend по смысловым слоям и слайсам, но WSD проще: меньше терминов, меньше уровней, меньше пространства для трактовок – но FSD я даже не посоветую, поскольку его каждый трактует по-разному, а вольные трактовки всегда доказывают, что он чересчурно сложный и не “понятный”.
- Vertical Slice Architecture — идея держать весь код одной функции рядом: UI, состояние, логику, тесты. Похоже на правило WSD: “переносишь папку — переносишь функцию”.
- Colocation — принцип “держать связанный код рядом”: компонент, состояние, тесты, стили, локальные utils.
- Locality of Behavior — логика должна лежать рядом с местом, где она реально используется, а не в абстрактной глобальной папке.
- Clean Architecture / Hexagonal Architecture — полезны как аналогия для libs/business-логики, но для frontend часто слишком тяжелые.
- Package by Feature — структура не по техническим типам файлов, а по пользовательским/бизнес-возможностям.
Подписывайтесь на тг канал [ $davids.sh ] , где будут выходить анонсы новых глав и обновления существующих