grainlet
细粒度的响应式UI: 信号, JSX, 查询, 形式, 身份验证, i18n, API路由历史, SSR /水合物。
安装
npm install grainlet
对于Vite JSX(仅限开发)-安装为 devDependencies):
npm install -D grainlet-vite vite @babel/core @babel/plugin-syntax-jsx
或者脚手架一个项目:
npx create-grainlet my-app
用法
import { createSignal, render } from 'grainlet';
function App() {
const [count, setCount] = createSignal(0);
return (
<button type="button" onClick={() => setCount((c) => c + 1)}>
{count()}
</button>
);
}
render(App, document.getElementById('app'));
轻快地
import { defineConfig } from 'vite';
import { grainJsx } from 'grainlet-vite';
export default defineConfig({
plugins: [grainJsx()],
esbuild: {
jsx: 'automatic',
jsxImportSource: 'grainlet',
},
});
入口点
| 进口 | 目的 |
|---|---|
grainlet | 核心API(信号、流、渲染、水合物) |
grainlet/route | API路由历史(Router, Route, Link,……) |
grainlet/forms | 表格状态(FormProvider, Field, createForm…)-看 形式/ README.md |
grainlet/auth | 会话验证(createAuth, AuthProvider, useSession…)-看 身份验证/ README.md |
grainlet/auth-sdk | 谷歌,Apple和GitHub OAuth集成-参见 auth-sdk / README.md |
grainlet/i18n | 翻译(createI18n, I18nProvider, useTranslation…)-看 i18n / README.md |
grainlet/query | 异步服务器状态(QueryClient, useQuery, useMutation…)-看 查询/ README.md |
grainlet/utils | 通用帮助程序(stableHash, replaceEqualDeep, Subscribable…)-看 跑龙套/ README.md |
grainlet/store | 细粒度代理存储(createStore, produce, reconcile) -见 存储/ README.md |
grainlet/testing | 组件、钩子、事件、等待和水合测试帮助程序——参见 测试/ README.md |
grainlet/devtools | 开发信号/所有者和查询检查器-参见 devtools / README.md |
grainlet/ssr | 缓存/流式SSR、SSG、head和Web/Node适配器-参见 ssr / README.md |
grainlet/jsx-runtime | 自动JSX运行时 |
grainlet-vite | grainJsx() Vite插件(单独的包, devDependency) |
grainlet-adapters | Vercel和Cloudflare SSR部署适配器(单独的包) |
打破: 路由器和SSR api不再重新导出
grainlet. 从grainlet/route和grainlet/ssr. 表单、auth、auth-sdk和i18n都在各自的目录下grainlet/*只有入口点。
信号和生命周期
import {
batch,
createDeferred,
createEffect,
createMemo,
createSignal,
isServer,
onCleanup,
onMount,
startTransition,
untrack,
useTransition,
} from 'grainlet';
| API | 角色 |
|---|---|
createSignal(initial) | [get, set] - - - - - - get() 读、 set(v) 或 set(prev => next) 写 |
createMemo(fn) | 缓存的访问器;当依赖项改变时重新计算 |
createEffect(fn) | 当依赖改变时运行;可能返回清理。服务器上没有操作 |
onMount(fn) | 一旦组件的DOM被挂载;可能返回清理。服务器上没有操作 |
onCleanup(fn) | 在处理当前效果或组件时运行 |
batch(fn) | 合并写道;一次刷新效果 |
untrack(fn) | 不订阅地读取信号 |
startTransition(fn) | 安排非紧急更新;返回一个 Promise 这在同花顺之后就解决了 |
useTransition() | [pending, start] - - - - - - pending() 在转换刷新时为true |
createDeferred(source, { timeoutMs? }) | 的延期副本 source,更新至 startTransition |
isServer() | 在SSR期间正确 |
onMount
onMount 在拥有的DOM存在之后运行,因此参考和度量是有效的。它
不能在以后的更新中重新运行。返回一个函数注册清理
卸载。
function Chat() {
let root;
onMount(() => {
root.focus();
const id = setInterval(poll, 5000);
return () => clearInterval(id);
});
return <section ref={(el) => { root = el; }} tabIndex={-1}>…</section>;
}
使用 onMount 用于一次性DOM设置(焦点、观察者、订阅)。使用
createEffect 当工作信号发生变化时应重新运行;它也可能返回
清理,或注册额外的拆卸 onCleanup。
function Title(props) {
createEffect(() => {
document.title = props.title();
});
onCleanup(() => {
document.title = 'Grainlet';
});
}
批处理和转换
默认同步写入刷新效果。 batch 将几个集合组合成一个集合
冲洗。 startTransition 保持紧急路径的冲洗,所以输入和
点击保持快速,而一个大的列表赶上。
const [first, setFirst] = createSignal('');
const [last, setLast] = createSignal('');
const [filter, setFilter] = createSignal('');
const [pending, start] = useTransition();
const deferredFilter = createDeferred(filter);
createEffect(() => {
first();
last();
// runs once per batch, not twice
});
batch(() => {
setFirst('Ada');
setLast('Lovelace');
});
function Search() {
return (
<>
<input
value={filter()}
onInput={(e) => setFilter(e.currentTarget.value)}
/>
<button
type="button"
disabled={pending()}
onClick={() => start(() => setFilter(filter().trim()))}
>
Apply
</button>
<List filter={deferredFilter()} />
</>
);
}
isServer() 内心是真实的 renderToString* /流式SSR。跳过浏览器api
在那里; onMount 和 createEffect 服务器上已经没有操作了。
商店
嵌套对象和数组-从 grainlet/store. 读取记录
个人路径;一个setter调用是批处理的。
import { createStore, produce, reconcile } from 'grainlet/store';
const [state, setState] = createStore({
user: { name: 'Ada', age: 1 },
todos: [{ title: 'Ship', done: false }],
});
setState('user', 'name', 'Grace');
setState({ user: { name: 'Grace', age: 2 } });
setState('todos', 0, produce((todo) => {
todo.done = true;
}));
setState('user', reconcile({ name: 'Grace', age: 3 }));
createEffect(() => {
state.user.name; // does not re-run when age changes
});
produce 使克隆的草稿发生变异。 reconcile 在保存时应用新树
不变的节点。完整的指南: 存储/ README.md。
控制流
import {
Show,
For,
VirtualList,
Switch,
Match,
Suspense,
ErrorBoundary,
Portal,
createContext,
useContext,
createResource,
lazy,
} from 'grainlet';
| 组件 | 角色 |
|---|---|
Show | 渲染子代 when 真相是什么 fallback) |
For | 地图 each 给孩子列清单;更喜欢 item.id 键 |
VirtualList | 窗口列表(垂直或水平)-监视,可选 debounceTime,无限滚动via onEndReached |
Switch / Match | 第一个匹配 when 分支 |
Suspense | fallback 而嵌套 createResource / lazy 正在等待 |
ErrorBoundary | 捕捉渲染/更新错误; fallback 或 (error, reset) => … |
Portal | 使儿童成为 document.body (或 mount) |
createContext / useContext | 共享数据而不需要钻柱(Provider +消费) |
createResource | 异步数据 [resource] - - - - - - resource() 读值 |
lazy | 惰性加载组件(lazy(() => import('./Page.js'))) |
更喜欢这些 {cond() && <X/>} 因此,条件更新无需重新运行父进程。
<VirtualList
each={items()}
itemHeight={48}
height={400}
overscan={5}
aria-label="Items"
>
{(item) => <div class="row">{item.label}</div>}
</VirtualList>
// The outer scroller accepts normal div props, a DOM ref, and an imperative API.
let scroller;
let listApi;
<VirtualList
each={items()}
itemHeight={48}
height={400}
id="search-results"
aria-label="Search results"
data-testid="results-list"
ref={(element) => (scroller = element)}
apiRef={(api) => (listApi = api)}
onScroll={(event) => console.log(event.currentTarget.scrollTop)}
>
{(item) => <div class="row">{item.label}</div>}
</VirtualList>
listApi?.scrollToIndex(250, { align: 'center', behavior: 'smooth' });
listApi?.scrollToItem(items()[0]);
listApi?.scrollToOffset(0);
listApi?.getVisibleRange(); // { start, end }
listApi?.getElement() === scroller; // true
<VirtualList
orientation="horizontal"
each={items()}
itemWidth={120}
width={480}
height={80}
debounceTime={32}
aria-label="Horizontal items"
data-layout="horizontal"
apiRef={(api) => (listApi = api)}
>
{(item) => <div class="card">{item.label}</div>}
</VirtualList>
// Infinite scroll — parent owns fetch + append
const [page, setPage] = createSignal([]);
const [loading, setLoading] = createSignal(false);
const [hasMore, setHasMore] = createSignal(true);
async function loadMore() {
if (loading() || !hasMore()) return;
setLoading(true);
const next = await fetchPage(page().length);
setPage((list) => [...list, ...next.items]);
setHasMore(next.hasMore);
setLoading(false);
}
<VirtualList
each={page()}
itemHeight={88}
height={520}
onEndReached={loadMore}
endReachedThreshold={0.2}
endReachedLoading={loading()}
aria-label="Infinite results"
aria-busy={loading()}
apiRef={(api) => (listApi = api)}
>
{(item) => <div class="row">{item.label}</div>}
</VirtualList>
<ErrorBoundary
fallback={(error, reset) => (
<div>
<p>Something went wrong: {error.message}</p>
<button type="button" onClick={reset}>Try Again</button>
</div>
)}
>
<ErrorProne />
</ErrorBoundary>
事件处理程序和 setTimeout 回调不会被捕获(与Solid相同)。
<div style={{ overflow: 'hidden' }}>
<Portal>
<div class="popup">Escapes overflow clipping</div>
</Portal>
</div>
<Portal mount={document.querySelector('#portal-root')}>
<p>Custom mount node</p>
</Portal>
{/* SVG: wrap in <g>; head: no wrapper */}
<Portal mount={svgEl} isSVG>
<circle r="4" />
</Portal>
const CounterContext = createContext();
function CounterProvider(props) {
const [count, setCount] = createSignal(props.count ?? 0);
return (
<CounterContext.Provider value={[count, setCount]}>
{props.children}
</CounterContext.Provider>
);
}
function useCounter() {
const value = useContext(CounterContext);
if (!value) throw new Error('Missing CounterProvider');
return value;
}
function Child() {
const [count, setCount] = useCounter();
return (
<button type="button" onClick={() => setCount((n) => n + 1)}>
{count()}
</button>
);
}
传递信号(或存储)为 value 所以消费者不用重新挂载Provider就能保持响应状态。
参考文献
附加 ref 到任何主机元素获取DOM节点(Solid-compatible):
// Callback
<input ref={(el) => { inputEl = el; }} />
// Signal setter (stable function identity)
const [el, setEl] = createSignal(null);
<div ref={setEl} />
// Variable form — requires grainJsx() (rewrites to an assignment callback)
let box;
<div ref={box} />
// Forwarding
function Field(props) {
return <input ref={props.ref} />;
}
<Field ref={setEl} />
在创建节点时设置Refs,并使用 null 当它被移除时(例如在内部) Show).
打印稿
类型随包一起发布(无需额外安装)。使用相同的Vite配置 jsxImportSource: 'grainlet':
import { createSignal, render, type Accessor } from 'grainlet';
function App() {
const [count, setCount] = createSignal(0);
return (
<button type="button" onClick={() => setCount((c) => c + 1)}>
{count()}
</button>
);
}
render(App, document.getElementById('app')!);
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "grainlet"
}
}
grainlet-vite 包括以下类型 grainJsx()。
形式
formik启发的形式-从 grainlet/forms:
import {
FormProvider,
Form,
Field,
ErrorMessage,
createForm,
useFormContext,
required,
isEmail,
minLength,
} from 'grainlet/forms';
function Signup() {
return (
<FormProvider
initialValues={{ email: '', password: '' }}
rules={{
email: [required('Email is required'), isEmail('Enter a valid email')],
password: [
required('Password is required'),
minLength(8, 'Use at least 8 characters'),
],
}}
onSubmit={async (values) => {
await api.signup(values);
}}
>
<Form>
<Field name="email" type="email" />
<ErrorMessage name="email" />
<Field name="password" type="password" />
<ErrorMessage name="password" />
<button type="submit">Sign up</button>
</Form>
</FormProvider>
);
}
无头(无提供商):
const form = createForm({
initialValues: { email: '' },
rules: { email: [required(), isEmail()] },
onSubmit: async (values) => { /* … */ },
});
// form.values(), form.handleChange, form.handleSubmit, …
支持嵌套路径(social.facebook, friends[0]),字段级 validate={[required(), isEmail()]},可选 validationSchema, FieldArray. 完整的指南: 形式/ README.md。
身份验证
无头,auth .js风格的会话-从 grainlet/auth. 你的应用拥有API、cookies和OAuth SDK:
import { Show, render } from 'grainlet';
import {
AuthProvider,
Credentials,
createAuth,
createLocalStorageAdapter,
useSession,
ProtectedRoute,
} from 'grainlet/auth';
const auth = createAuth({
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: (credentials) =>
api.post('/api/auth/login', credentials),
}),
],
refresh: ({ userId, refreshToken }) =>
api.post('/api/auth/refresh', { userId, refreshToken }),
storage: createLocalStorageAdapter(),
});
function AccountMenu() {
const session = useSession();
return (
<Show
when={() => session.status() === 'authenticated'}
fallback={
<button
type="button"
onClick={() =>
session.signIn('credentials', {
email: '[email protected]',
password: 'secret',
})
}
>
Sign in
</button>
}
>
<span>{session.data()?.user.email}</span>
<button type="button" onClick={() => session.signOut()}>
Sign out
</button>
</Show>
);
}
function SettingsRoute() {
return (
<ProtectedRoute
redirectTo="/auth/signin"
loadingFallback={<p>Checking session…</p>}
>
<Settings />
</ProtectedRoute>
);
}
render(
() => (
<AuthProvider client={auth}>
<AccountMenu />
</AuthProvider>
),
document.getElementById('app')
);
useSession() 返回认证客户端。访问器: data(), status() (loading | authenticated | unauthenticated), error(). 另外: signIn, signOut, getSession, refresh, update. 默认存储是仅内存;使用 createLocalStorageAdapter() 在重新加载时持久化。完整的指南: 身份验证/ README.md。
对于电池(包括谷歌,Apple和GitHub登录),请配置
createAuthSdk() 并添加 GoogleSignIn(sdk), AppleSignIn(sdk),
GitHubSignIn(sdk) 到providers数组。SDK处理提供程序脚本,
弹出窗口,令牌,并调用您的后端。完整的指南:
auth-sdk / README.md。
i18n
命名空间JSON转换-从 grainlet/i18n:
import { render } from 'grainlet';
import { createI18n, I18nProvider, useTranslation } from 'grainlet/i18n';
import enCommon from './locales/en/common.json';
import ukCommon from './locales/uk/common.json';
// en/common.json — { "welcome": "Welcome, {name}", "actions": { "continue": "Continue" } }
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
resources: {
en: {
common: enCommon,
// Lazy namespace: auth: () => import('./locales/en/auth.json'),
},
uk: { common: ukCommon },
},
});
function Welcome() {
const { t, locale, setLocale } = useTranslation('common');
return (
<main>
<h1>{t('welcome', { name: 'Mike' })}</h1>
<p>{t('actions.continue')}</p>
<button type="button" onClick={() => setLocale(locale() === 'en' ? 'uk' : 'en')}>
Switch language
</button>
</main>
);
}
render(
() => (
<I18nProvider client={i18n}>
<Welcome />
</I18nProvider>
),
document.getElementById('app')
);
将名称空间传递给 useTranslation,然后使用相对于该文件的键。嵌套键使用点路径(segment.title.key). 丢失的钥匙落回 fallbackLocale,然后是关键字串。对于表单验证器,传递惰性消息: required(() => t('validation.required')). 完整的指南: i18n / README.md。
查询
异步服务器状态-从 grainlet/query. 查询和突变字段
是Grainlet访问器(data(), isPending()),而不是普通属性。
import { QueryClient, QueryClientProvider, useQuery } from 'grainlet/query';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
function Todo({ id }) {
const todo = useQuery({
queryKey: ['todo', id],
queryFn: ({ signal }) =>
fetch(`/api/todos/${id}`, { signal }).then((r) => r.json()),
});
return <h1>{todo.data()?.title}</h1>;
}
另外: useMutation, useInfiniteQuery, useQueries, HydrationBoundary,
dehydrate / hydrate. 完整的指南: 查询/ README.md。
路由
历史API路由器-不需要哈希
import {
Router,
Route,
Link,
navigate,
useParams,
useLocation,
} from 'grainlet/route';
function User() {
const params = useParams();
const location = useLocation();
return (
<section>
<h2>User {params().id}</h2>
<p>{location().pathname}</p>
</section>
);
}
function App() {
return (
<>
<nav>
<Link href="/" activeClass="active">Home</Link>
<Link href="/users/1" activeClass="active">User 1</Link>
</nav>
<Router basename="/app">
<Route path="/" component={Home} />
<Route path="/users/:id" component={User} />
<Route path="*" component={NotFound} />
</Router>
</>
);
}
// Imperative navigation (respects Router basename)
navigate('/users/3');
| API | 角色 |
|---|---|
Router | 匹配位置 Route 孩子(可选 basename) |
Route | path + component (:param, * 笼统) |
Link | 客户端 <a>; activeClass 当路径匹配时 |
navigate(to) | 推动历史/替换 |
useParams() | 路由参数信号(params().id) |
useLocation() | 的信号 { pathname, search, hash, state } |
嵌套布局、加载器和动作都是可选择的 mode="nested":
import {
Link,
Outlet,
Route,
Router,
queryLoader,
useNavigation,
useRouteLoaderData,
} from 'grainlet/route';
function Layout() {
const navigation = useNavigation();
return (
<main>
<Link href="/">Home</Link>
<p>{() => navigation().state}</p>
<Outlet />
</main>
);
}
function Project() {
const project = useRouteLoaderData();
return <h1>{() => project().name}</h1>;
}
<Router mode="nested" queryClient={queryClient} hydrationState={routeState}>
<Route element={Layout}>
<Route
path="projects/:id"
id="project"
component={Project}
loader={queryLoader(({ params }) => ({
queryKey: ['project', params.id],
queryFn: () =>
fetch(`/api/projects/${params.id}`).then((r) => r.json()),
}))}
/>
</Route>
</Router>
queryLoader 共享路由器 queryClient 与 useQuery. 另外:
useSubmit, useMatches, useOutletContext, useRouteActionData,
useRouteError, useSearchParams, redirect. 在服务器端,
prepareRoutes / renderRouteDocument 在HTML之前运行加载器;呼叫
hydrateRouterState(state, queryClient) 客户端挂载前。完整的指南:
路线/ README.md。
苏维埃社会主义共和国
import { Suspense, lazy, hydrate } from 'grainlet';
import {
renderToString,
renderToStringAsync,
wrapHtmlDocument,
} from 'grainlet/ssr';
const body = renderToString(App, {}, { url: 'http://localhost/' });
// Await lazy / createResource before emitting HTML (resolved content, not fallback)
const Home = lazy(() => import('./pages/home.js'));
function App() {
return (
<Suspense fallback={<p>Loading</p>}>
<Home />
</Suspense>
);
}
const asyncBody = await renderToStringAsync(App);
在服务器端, createEffect 是no-op; createMemo 对HTML快照求值一次。
renderToStringAsync 保持SSR模式在交叉通道直到悬疑工作解决。
生产应用应该使用 createRequestHandler (网络 Response)或节点
createNodeHandler 出口。流媒体使用 renderToReadableStream 和
renderRouteToReadableStream:文档外壳和悬疑回退刷新
首先,用逃逸的边界补丁替换无序的已分解区域。
加载器的重定向发生在shell之前。炮弹发射后,失效
待在它们的边界内,浮出水面 onError。
总是创建一个 QueryClient 每个请求。 head 是转义文本;信任
标记属于 unsafeHead. 更喜欢 Title, Meta, HeadLink, Canonical,
JsonLd, OpenGraph. 通过CSP考试 nonce 所以状态脚本和流
补丁继承它。 prerenderPaths / writePrerendered 生成静态HTML
加上一个 grainlet-prerender.json 清单。将这个管道与
npx create-grainlet my-app --ssr。
configureHydration({ onMismatch, strict }) 报告预期/实际节点;
JSX源码,以及一个组件栈。水合不匹配替换子树
除非严格模式抛出。完整的指南: ssr / README.md。
测试
grainlet/testing 使用来自Vitest、Jest、JSDOM、happy-dom或
浏览器。它不捆绑DOM实现。
import {
cleanup,
fireEvent,
hydrate,
render,
renderHook,
screen,
waitFor,
} from 'grainlet/testing';
const view = render(Counter);
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
await waitFor(() => screen.getByText('1'));
view.unmount();
cleanup();
通过 html 来 hydrate 附加到现有标记而不清除
容器。完整的指南: 测试/ README.md。
Devtools
在创建应用程序信号之前安装钩子,然后呈现面板 仅在开发阶段:
import { GrainletDevtools, installDevtoolsHook } from 'grainlet/devtools';
installDevtoolsHook();
<GrainletDevtools queryClient={queryClient} />
检查器显示信号/效果/所有者事件和查询缓存状态。全 导游: devtools / README.md。