У каждого из нас есть приложения с представлениями. Вы знаете, что представления — это макрокомпоненты, которые мы используем для создания страниц. Эти страницы затем связываются с нашими маршрутами для создания наших приложений.
В этом посте мы расскажем вам о создании чего-то, напоминающего Relay EntryPoints, о том, что это значит для этих представлений. И что это не совсем история о пользовательских интерфейсах, а история о том, где живут ваши данные.
- Точки входа
- Точки входа безопасны для типов
- Точки входа описывают зависимость данных
- Необходимая инкапсуляция
- Точки входа описывают зависимость от ui.
- Почему данным не место в React
- Точки входа описывают рендеринг и загрузку
- Точки входа построить GitHub4
- Модалы
- Точки входа могут защитить ваши маршруты
- Резюме
- Читать далее
- Что такое JSResource?
- Спасибо
Точки входа
У нас есть макет представлений, коллекция, которую мы любим называть страницами. Но давайте задумаемся на мгновение, что это на самом деле?
В React нам нравится думать, что мы составляем компоненты, то есть то, что вы помещаете внутрь другого объекта.1. Так что давайте на секунду задумаемся об этом.
Мы видим, что у нас есть что-то похожее на GitHub:
- глобальный навигатор, сохраняющийся на нескольких страницах
- заголовок проекта, сохраняющийся на нескольких вкладках проекта
- вкладка кода, или «основное содержание».
Все эти представления или компоненты, которые при объединении вместе образуют страницу репозитория. Наша страница объединила все эти компоненты в один корневой узел, который мы назовем RepositoryView
.
Пока терминология не вышла из-под контроля, давайте вместо этого будем называть это «составлением макетов точек входа».
Итак, нашей точкой входа здесь будет сама страница, RepositoryViewEntryPoint
, ProjectHeaderEntryPoint
и, конечно же, основное содержимое.
Все эти вещи являются строительными блоками нашего приложения — «точкой входа» в кусок кода (его представление) и его данные.
Давайте распакуем, что такое точки входа, и пройдем полный круг к концу, когда мы создадим GitHub.
Точки входа безопасны для типов
Прежде чем мы узнаем, как Relay справляется с этим, давайте создадим свою собственную! 😅
В создании точки входа участвуют три части.
- определение типа того, что вообще такое точка входа
- компонент
- и код точки входа для этого компонента
// 1. the type definition
type EntryPoint<Props> = {
component: ComponentType<Props>,
props: Props
};
// 2. the component
type Props = { user: { name: string } };
const Howdy: SFC<Props> = ({ user }) => (
<div>Hello {user.name}</div>
);
// 3. the entry point
const HowdyEntryPoint: EntryPoint<Props> = {
component: Howdy,
props: { user: { name: 'Mona' } },
};
… и теперь вы думаете, что я совсем сбился с пути! 😅 «Вы просто поставили реквизит компонента рядом с чем-то, что уже определяет его»… но не мешайте мне.
Что мы сделали здесь, так это создали контейнер, создающий безопасную для типов зависимость между компонентом и его данными.
Не позволяйте мне недооценивать это, одним из мощных аспектов компонентов, особенно с помощью Flow и TypeScript, является возможность определять типы реквизитов компонентов. Поэтому, когда мы переходим к понятию «Не помещайте данные в React», нам нужно сохранить этот аспект безопасности типов.
Если компоненту потребуется новый набор требований к данным, поскольку мы установили между ними зависимость, безопасную для типов, вы не забудете передать эти новые данные компоненту — ваша система проверки типов будет скулить.
Но как мы перенесли данные из React? Действительно в прямом смысле: <Howdy user={{ name: 'mona' }} />
, имеет { name: 'mona' }
как data declare in-react. Таким образом, мы переместили данные вместе со ссылкой на компонент в виде объекта присоединения, EntryPoint
.
Отлично! Давайте выведем это на экран, что произойдет, как вы и ожидали:
const EntryPointContainer: SFC<{ entrypoint: EntryPoint<unknown> }> = ({
entrypoint: {
component: Component,
props,
},
}) => (
<Component {...props} />
);
<EntryPointContainer entrypoint={HowdyEntryPoint} />
Здесь EntryPointContainer
принимает ссылку на точку входа и соединяет реквизиты и рендеринг.
Точки входа описывают зависимость данных
Сейчас! Разве мир не был бы прекрасен, если бы у нас были только статические данные. Если бы это было так, то пост бы на этом закончился 😂! Вместо этого мы живем в мире, где наши данные живут в удаленных местах, в базах данных, в apis, в ящике ваших двоюродных бабушек.
Поэтому давайте немного изменим наше мышление, вместо вопроса «какие данные идут с этим компонентом» давайте спросим «какой запрос я должен выполнить, чтобы получить данные для этого компонента».
Точка входа описывает зависимость данных.
Что значит описать?
дать отчет о том, как что-то делается — Кембридж.
Обратите внимание, что это «как что-то сделано», а не «что это за что-то».
В терминах программного обеспечения, как мы описываем, как данные делаются или извлекаются? Возможно, через функцию? Функция описывает способ обработки данных, а не результат.
Давайте опишем зависимость данных и изменим наш пример, чтобы отразить это:
type EntryPoint<Variables, Props> = {
component: ComponentType<Props>,
fetch: (variables: Variables) => Promise<Props>,
variables: Variables
};
const HowdyEntryPoint: EntryPoint<{ userId: string }, Props> = {
component: Howdy,
fetch(variables) {
return fetchGraphql(graphql`query($id: ID) { user(id: $id) { name }}`);
},
variables: { userId: 2 },
};
Вместо того чтобы передавать props
, которые у нас были статически. Мы определяем описывающую функцию, как разрешить данные, в нашем случае путем вызова некоторого api. Как и большинство функций, они могут принимать некоторые входные данные, чтобы сделать их настраиваемыми, давайте раскроем это с помощью переменных
.
Для целей этой заметки используйте свое воображение, откуда взять эти переменные
, но это может быть что-то вроде useParams
из вашей любимой библиотеки маршрутизации.
Наш компонент EntryPointContainer
также должен быть немного изменен для работы с новыми свойствами fetch
и variables
.
const EntryPointContainer: SFC<{ entrypoint: EntryPoint<unknown, unknown> }> = ({
entrypoint: {
component: Component,
fetch: fetchQuery,
variables,
},
}) => {
const [props, setProps] = useState(null);
useEffect(() => {
fetchQuery(variables)
.then(props => {
setProps(props);
});
}, [fetch, variables]);
if (props === null) return null;
return <Component {...props} />;
};
Простая вещь, useEffect
.2 для вызова нашей функции fetch и рендеринг Component
только после получения данных.
… и использование остается прежним!
<EntryPointContainer entrypoint={HowdyEntryPoint} />
На самом деле мы можем пойти на шаг дальше. Мы все здесь используем GraphQL. Поэтому вместо передачи функции fetch давайте опишем данные с помощью GraphQL-запроса 🦸♂️.
type EntryPoint<Variables, Props> = {
component: ComponentType<Props>,
query: string,
variables: Variables
};
const HowdyEntryPoint: EntryPoint<{ userId: string }, Props> = {
component: () => import('./howdy'),
query: /* GraphQL */`query($id: ID) { user(id: $id) { name }}`,
variables: { userId: 2 },
};
Необходимая инкапсуляция
Только что мы описали зависимость данных в виде высокоуровневого запроса GraphQL. И я не могу переоценить этот момент, который стал поворотным в нашем понимании точек входа.
Мы перенесли платформенный уровень, функцию выборки в описывающий фактор, оставив инженеров платформы свободными для реализации логики выборки от нашего имени и в их темпе.
Я уже говорил: «Функция описывает, как данные разрешаются, а не их результат», но проблема с функциями в том, что они тяжелые — часто связаны с каким-то сетевым уровнем, поэтому несут слишком много определений.
Точки входа описывают зависимость от ui.
Отлично! Наши точки входа теперь могут монтироваться, а данные описаны.
Но подождите… У нас все еще есть синхронная связка кода. Возможно, для этого момента есть целая статья.
Если мы продолжим это представление о том, что точки входа являются описывающими контейнерами, то нам нужно будет описать и наш компонент — это все еще данные, не описывающие данные.
Так что давайте это исправим…
И лучший способ сделать это — использовать наши надежные функции импорта esm.
type EntryPoint<Variables, Props> = {
component: () => Promise<ComponentType<Props>>,
query: string,
variables: Variables
};
const EntryPointContainer: SFC<{ entrypoint: EntryPoint<unknown, unknown> }> = ({
entrypoint: {
component,
query,
variables,
},
}) => {
const [props, setProps] = useState(null);
const [Component, setComponent] = useState(null);
useEffect(() => {
fetchQuery(query, variables)
.then(props => {
setProps(props);
});
}, [query, variables]);
useEffect(() => {
component()
.then(Component => {
setComponent(Component);
});
}, [component]);
if (props === null || Component === null) return null;
return <Component {...props} />;
};
… компонент и данные разделяются, создавая тонкий последовательный json.3 определение, как нарисовать эту точку входа 🦄.
Нужно быстро исправить наш HowdyEntryPoint
для использования этих новых свойств:
const HowdyEntryPoint: EntryPoint<{ userId: string }, Props> = {
component: () => import('./howdy'),
query: /* GraphQL */`query($id: ID) { user(id: $id) { name }}`,
variables: { userId: 2 },
};
… и все по-прежнему отображается одинаково!
✨ У вас получилось! Поздравляем 🎉, вы создали Relay Entry Points!
Мы превратили то, что было «кодом» в то, что теперь является «описанием»!
Есть только одна вещь… Отлично! Мы перенесли данные из React и как, но почему?
Почему данным не место в React
Если мы переключим передачу и посмотрим на этот вопрос с точки зрения сервера, которому нужно подготовить данные, необходимые для страницы.
Если бы все данные были в React (а они не статичны, как уже говорилось), как бы он узнал, какие данные нужно подготовить? Нам пришлось бы рендерить все дерево React, чтобы обнаружить эти вещи, что является довольно дорогостоящим мероприятием.
Этой теме и тому, как может работать маршрутизация, посвящена целая статья. Но чтобы помочь мне в этой статье, давайте просто скажем, что маршруты указывают на точки входа. Поэтому, когда сервер получает запрос на маршрут, мы можем просмотреть все наши точки входа и выбрать ту, которая соответствует.
Таким образом, у нас есть статический/постоянный доступ к требуемым данным — и как их получить.
Вот и все, вот почему! Ну и ну, Маре, долго же ты ждал!
Давайте продолжим рассмотрение того, как мы можем решить эту проблему. Умные люди, возможно, поняли, что топология нашего приложения изначально описывала глобальные навигаторы, основное содержимое, заголовки проектов и т.д.. Если это все «Точки входа», которые мы составили.
Мы бы получили довольно неприятные водопадные загрузки 😭, так что давайте это исправим!
Точки входа описывают рендеринг и загрузку
Теперь мы влезаем в гущу событий, связанных с загрузкой ресурсов, и Suspense, вероятно, стоит посмотреть в первую очередь. В двух словах — suspense — это способ для React обрабатывать обещания за нас.
В первом примере у нас были наши данные, 👋 mona. Все, что нам было нужно, это { props: { data } }
, и готово. Теперь у нас есть промежуточное состояние загрузки, махинации с api, с которыми нужно разбираться.
Было бы здорово, если бы мы могли взять наши определения точек входа и вернуть их в форму, где данные были бы статичными.
Давайте попробуем!
Что сразу приходит на ум, так это загрузка данных перед рендерингом:
// Something suspensey
type PreloadedEntryPoint<Data> = { ... };
const loadEntryPoint = <Variables, Props>(
entrypoint: EntryPoint<Variables, Props>,
variables: Variables,
): Promise<PreloadedEntryPoint<Props>> => { ... };
const EntryPointContainer: SFC<{ entrypoint: PreloadedEntryPoint<unknown> }> = ({
entrypoint,
}) => {
const { Component, props } = entrypoint.read(); // suspends
return <Component {...props} />;
};
loadEntryPoint(HowdyEntryPoint)
.then(entrypoint => {
ReactDOM.render(<EntryPointContainer entrypoint={entrypoint} />);
});
Очень похоже на нашу безопасную для типов зависимость, которую мы создали с помощью нашей точки входа. Мы создали еще один уровень безопасности типов, присоединив к точке входа безопасный в полете или предварительно загруженный контейнер данных. Это гарантирует, что мы передаем нужные предварительно загруженные данные нужному компоненту.
Теперь вы подумали. Мы должны явно передавать эти предварительно загруженные контейнеры данных, и задаетесь вопросом, почему?
На самом деле это очень хорошая вещь. Если он крякает как утка, плавает как утка, то называйте его уткой. Это дает понять, кому он нужен, кто его использует и, конечно, когда никто больше не использует, его можно удалить.
Нашему компоненту не нужно определение данных, ему нужны сами данные! Таким образом, с точки зрения компонента, он эффектно говорит: «Эй, мне нужны эти предварительно загруженные данные», что отвечает на вопрос «кому они нужны».
Вопрос «кто его использует» отслеживается путем передачи данных в компонент EntryPointContainer
. Мы не будем углубляться в концепцию Relay о подсчете ссылок, но идея в том, что когда предварительно загруженные данные больше не используются, мы можем удалить их из памяти. Потому что это безопасно. Ведь если они нам снова понадобятся, мы знаем, как их получить.
… и бам! Вы получили релеевское определение точек входа.
Давайте посмотрим на одну из них и построим GitHub!
Точки входа построить GitHub4
Как бы нам ни нравился наш компонент Howdy
, давайте определим что-то реальное, что вы ожидаете увидеть.
ProjectHeader
const ProjectHeader: SFC<{
queries: {
queryRef: PreloadedQuery<typeof ProjectHeaderQuery>
}
}> = ({ queries }) => {
const data = usePreloadedQuery(graphql`query ProjectHeaderQuery($owner: String, $repo: String) {
repository(owner: $owner, name: $repo) {
owner
name
stars
}
}`, queries.queryRef);
return <div>
<h1>{data.repository.owner}/{data.repository.name}</h1>
<button>Stars {data.repository.stars}</button>
</div>;
};
const ProjectHeaderEntryPoint: EntryPoint<{
owner: string,
repo: string
}> = {
root: JSResource('ProjectHeader'),
getPreloadedProps(params) {
return {
queries: {
queryRef: {
parameters: ProjectHeaderQuery,
variables: {
owner: params.owner,
user: params.repo,
},
},
},
};
},
};
RepositoryView
const RepositoryView: SFC<{
queries: {
queryRef: PreloadedQuery<typeof RepositoryViewQuery>
},
entryPoints: {
projectHeader: typeof ProjectHeaderPoint
}
}> = ({ queries, entrypoints }) => {
const data = usePreloadedQuery(graphql`query RepositoryViewQuery($owner: String, $repo: String) {
repository(owner: $owner, name: $repo) {
readme {
html
}
}
}`, queries.queryRef);
return <div>
<EntryPointContainer entrypoint={entrypoints.projectHeader}/>
<div>
<h2>Readme</h2>
<div dangerouslySetInnerHTML={{ __html: data.repository.readme.html }}/>
</div>
</div>;
};
const RepositoryViewEntryPoint: EntryPoint<{
owner: string,
repo: string
}> = {
root: JSResource('RepositoryView'),
getPreloadedProps(params) {
return {
queries: {
queryRef: {
parameters: RepositoryViewQuery,
variables: {
owner: params.owner,
user: params.repo,
},
},
},
entryPoints: {
projectHeader: ProjectHeaderEntryPoint,
},
};
},
};
Почитайте их, но наше приложение скомпонует их в нечто подобное:
Пожалуйста, не распинайте меня, пожалуйста, обработайте ошибки, крайние случаи и т.д.. В лучшем случае это псевдокод.
let routes = {
'/:owner/:repo': RepositoryViewEntryPoint,
};
const matchRoute = (url: string) => routes[url];
const initialPage = loadEntryPoint(matchRoute(location.href));
const App = () => {
const { entrypoint, setEntryPoint } = useState(initialPage);
useEffect(() => {
// Please use something like https://github.com/lukeed/navaid
window.addEventListener('pushstate', () => {
setEntryPoint(matchRoute(location.href));
});
}, []);
return <Suspense fallback={null}>
<EntryPointContainer entrypoint={entrypoint}/>
</Suspense>;
};
Ух ты! EntryPoints могут составлять другие EntryPoints!!!?!?!?!?!
Заголовок нашего проекта состоит из представления репозитория (или страницы, или макета), аналогично концепции Outlet
.
Когда эта точка входа верхнего уровня loadEntrypoint
будет рекурсивно вызывать getPreloadedProps
, и все сборщики данных и кода будут работать параллельно.
Модалы
… или вообще все, что стоит за взаимодействием с пользователем — это EntryPoint.
Поскольку «строительный блок» описывается как точка входа, мы можем предварительно или отсроченно загрузить его за взаимодействием с пользователем.
Например, «вылет кода» на GitHub, вылет требует — кодовые пространства пользователей, предпочтение ssh или html, и потенциально всевозможные другие ui и данные, которые не требуются для критической загрузки.
Затем мы можем объявить это как точку входа, например, так:
const CodeFlyout: SFC<{
queries: {
queryRef: PreloadedQuery<typeof CodeFlyoutQuery>
}
}> = ({ queries }) => {
const data = usePreloadedQuery(graphql`query CodeFlyoutQuery($owner: String, $repo: String) {
repository(owner: $owner, name: $repo) {
url {
ssh
https
}
codespaces {
name
url
}
}
viewer {
cloning_preference
}
}`, queries.queryRef);
return (<div>
<Tabs active={data.viewer.cloning_preference}>
<Item name="ssh">
<pre>{data.repository.url.ssh}</pre>
</Item>
<Item name="https">
<pre>{data.repository.url.https}</pre>
</Item>
</Tabs>
<p>Codespaces is awesome, you should use it</p>
{data.repository.codespaces.map(item => (
<a href={item.url}>Open codespace {item.name}</a>
))}
</div>);
};
const CodeFlyoutEntryPoint: EntryPoint<{
owner: string,
repo: string
}> = {
root: JSResource('CodeFlyout'),
getPreloadedProps(params) {
return {
queries: {
queryRef: {
parameters: CodeFlyoutQuery,
variables: {
owner: params.owner,
user: params.repo,
},
},
},
};
},
};
const RepositoryView = () => {
return (<div>
{ /* all the other stuff from above */}
<FlyoutTrigger entrypoint={CodeFlyoutEntryPoint}>
{({ onClick }) =>
(<button onClick={onClick}>Code</button>)
}
</FlyoutTrigger>
</div>);
};
Просто замечательно, мы декларативно составили то, что нужно нашей странице, и все это прекрасно с точки зрения UX. Биты, которые стоят за взаимодействием с пользователем, разделены по коду, и все отлично! И самое главное, он безопасен для типов!!!
Но на самом деле предел неба теперь в том, как вы его используете!
- Вы можете предварительно загружать точку входа при наведении.
- вы можете
intersection observer
, чтобы проверить, что все видимые ModalTrigers имеют свои точки входа предварительно загруженными
Точки входа могут защитить ваши маршруты
Обратите внимание, что объект routes
выше может быть получен из объекта окна, инкрементально гидратирован из api или чего угодно — это просто json.
Побочный момент, и кое-что важное ☝️.
Для обработки разрешений, доступа на чтение и открываемости маршрутов. Возможно, вы не захотите передавать всю карту точки входа клиенту. Вместо этого перед переходом к маршруту вы запрашиваете у сервера json точки входа — или не возвращаете ничего, например, 404.
Вы можете сделать что-то вроде:
useEffect(() => {
window.addEventListener('pushstate', () => {
const target = location.href;
fetch(`/routes?to=${target}`)
.then(route => {
if (route) {
Object.assign(routes, route);
setEntryPoint(matchRoute(target));
} else {
setEntryPoint(matchRoute('404'));
}
});
});
}, []);
… Пожалуйста, напишите что-нибудь получше этого, но идея такова. Либо при наведении, либо при нажатии — сначала спросите у вашего хорошо защищенного бэкенда, какая точка входа используется для этого маршрута.
Если он ничего не возвращает, то 404. Если возвращает — действуйте. Это означает, что все «этот пользователь может получить к нему доступ» и т.д. могут быть раскрыты, скрывая все обычные риски безопасности «маршрут существует, но пользователь не может его увидеть».
Думайте как о частном репозитории, если точка входа существует, и была предпринята попытка, то, возможно, вы можете использовать ее для других попыток.
Резюме
Давайте быстро подытожим, чего мы достигли, и убедимся, что вы уловили основные моменты.
- Точки входа — это тонкие json-сериализуемые определения того, какой код нужно запустить, и данных, которые могут понадобиться этому коду.
- Точки входа описывают зависимость от данных, а не сами данные.
- точки входа описывают зависимость кода.
- Точки входа безопасны для типов и статически анализируемы.
- точки входа загружаются и обрабатываются вне жизненного цикла react.
- точки входа должны обертывать вещи, которые находятся за взаимодействием с пользователем, маршрутные переходы находятся за взаимодействием с пользователем.
Читать далее
Что такое JSResource
?
Довольно просто функция, которая возвращает суспенсивную обертку вокруг обещания. Помните, я говорил, что точки входа могут быть сериализованы в json, так вот. JSResource
под капотом, это будет import('./components/${name}')
. Или как вы хотите решить эту проблему.
Пример реализации 👉 npm jsr
.
Спасибо
Особая благодарность Тому Гассону за вдохновение для статьи ❤️
Фото на обложке Иван Алексич
Следите за мной в twitter ~> @slightlycode
-
Нет, не John Cleese Royal Society For Putting Things On Top of Other Things, потому что это было бы довольно глупо.
-
Не используйте это в продакшене по причинам, связанным с границами ошибок, состояниями загрузки и так далее.
-
Просто нужно переместить наш асинхронный импорт в строку, которая просматривается/набирается аналогично тому, как это делается в запросе.
JSResource
будет вашим другом. -
Все это не соответствует тому, как устроен GitHub, не одобряется и не спонсируется им.