grainlet

中文文档
1.1.11发布于 2026/11/04MITTypeScriptES Module零依赖

用于web的细粒度信号、JSX、路由和SSR

中文使用文档
文档目录19 节
  1. 安装
  2. 用法
  3. 轻快地
  4. 入口点
  5. 信号和生命周期
  6. onMount
  7. 批处理和转换
  8. 商店
  9. 控制流
  10. 参考文献
  11. 打印稿
  12. 形式
  13. 身份验证
  14. i18n
  15. 查询
  16. 路由
  17. 苏维埃社会主义共和国
  18. 测试
  19. Devtools

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/routeAPI路由历史(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-vitegrainJsx() Vite插件(单独的包, devDependency)
grainlet-adaptersVercel和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 分支
Suspensefallback 而嵌套 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)
Routepath + 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。

版本与发布记录

grainlet 共发布 30 个版本,首次发布于 2026/07/17;过去 12 个月发布了 30 个版本。

版本发布日期距今
1.1.11latest2026/11/04今天
1.1.12026/08/222 个月前
1.1.02026/08/222 个月前
1.0.292026/08/122 个月前
1.0.282026/08/072 个月前
1.0.272026/08/072 个月前

关于 grainlet 的常见问题

grainlet 是什么?

用于web的细粒度信号、JSX、路由和SSR

如何安装 grainlet?

在项目目录运行 npm install grainlet,或使用 pnpm add / yarn add / bun add。

grainlet 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

grainlet 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

grainlet 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

grainlet 可以商用吗?

grainlet 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

grainlet 还在维护吗?

最新版本 1.1.11 发布于 2026/11/04(今天);至今共发布 30 个版本,其中过去 12 个月发布了 30 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。