# grainlet 中文文档

规范地址：https://npmcn.com/package/grainlet/

版本：1\.1\.11
npm 元数据更新时间：2026-11-03T17:50:30.181Z
中文译文更新时间：2026-11-03T19:09:20.569Z
软件包许可：MIT

本文由上游 README 机器翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/grainlet
原文对照：https://npmcn.com/package/grainlet/?lang=original

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：547 kB，164 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=20
- 运行依赖：0 个
- peer 依赖：yup ^1\.0\.0
- 首次发布：2026-07-16T17:12:00.814Z
- 最新版本发布：2026-11-03T17:50:29.992Z
- 已发布版本数：30（近 12 个月 30 个）

## 中文简介

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

## 安装

```sh
npm install grainlet
```

也可以使用：`pnpm add grainlet`、`yarn add grainlet`、`bun add grainlet`

## README

# grainlet

细粒度的响应式UI： **信号**， **JSX**， **查询**， **形式**， **身份验证**， **i18n**， **API路由历史**, **SSR /水合物**。

## 安装

```bash
npm install grainlet
```

对于Vite JSX（仅限开发）-安装为 `devDependencies`）：

```bash
npm install -D grainlet-vite vite @babel/core @babel/plugin-syntax-jsx
```

或者脚手架一个项目：

```bash
npx create-grainlet my-app
```

## 用法

```js
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'));
```

## 轻快地

```js
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](https://npmcn.com/package/grainlet/forms/README.md) |
| `grainlet/auth` | 会话验证(`createAuth`， `AuthProvider`， `useSession`…)-看 [身份验证/ README.md](https://npmcn.com/package/grainlet/auth/README.md) |
| `grainlet/auth-sdk` | 谷歌，Apple和GitHub OAuth集成-参见 [auth-sdk / README.md](https://npmcn.com/package/grainlet/auth-sdk/README.md) |
| `grainlet/i18n` | 翻译(`createI18n`， `I18nProvider`， `useTranslation`…)-看 [i18n / README.md](https://npmcn.com/package/grainlet/i18n/README.md) |
| `grainlet/query` | 异步服务器状态(`QueryClient`， `useQuery`， `useMutation`…)-看 [查询/ README.md](https://npmcn.com/package/grainlet/query/README.md) |
| `grainlet/utils` | 通用帮助程序(`stableHash`， `replaceEqualDeep`， `Subscribable`…)-看 [跑龙套/ README.md](https://npmcn.com/package/grainlet/utils/README.md) |
| `grainlet/store` | 细粒度代理存储(`createStore`， `produce`， `reconcile`) -见 [存储/ README.md](https://npmcn.com/package/grainlet/store/README.md) |
| `grainlet/testing` | 组件、钩子、事件、等待和水合测试帮助程序——参见 [测试/ README.md](https://npmcn.com/package/grainlet/testing/README.md) |
| `grainlet/devtools` | 开发信号/所有者和查询检查器-参见 [devtools / README.md](https://npmcn.com/package/grainlet/devtools/README.md) |
| `grainlet/ssr` | 缓存/流式SSR、SSG、head和Web/Node适配器-参见 [ssr / README.md](https://npmcn.com/package/grainlet/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/*` 只有入口点。

## 信号和生命周期

```js
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存在之后运行，因此参考和度量是有效的。它 不能在以后的更新中重新运行。返回一个函数注册清理 卸载。

```js
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`。

```js
function Title(props) {
  createEffect(() => {
    document.title = props.title();
  });

  onCleanup(() => {
    document.title = 'Grainlet';
  });
}
```

### 批处理和转换

默认同步写入刷新效果。 `batch` 将几个集合组合成一个集合 冲洗。 `startTransition` 保持紧急路径的冲洗，所以输入和 点击保持快速，而一个大的列表赶上。

```js
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调用是批处理的。

```js
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](https://npmcn.com/package/grainlet/store/README.md)**。

## 控制流

```js
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/>}` 因此，条件更新无需重新运行父进程。

```js
<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>
```

```js
<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相同）。

```js
<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>
```

```js
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）：

```js
// 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'`：

```ts
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')!);
```

```json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "grainlet"
  }
}
```

`grainlet-vite` 包括以下类型 `grainJsx()`。

## 形式

formik启发的形式-从 **`grainlet/forms`**：

```js
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>
  );
}
```

无头（无提供商）：

```js
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](https://npmcn.com/package/grainlet/forms/README.md)**。

## 身份验证

无头，auth .js风格的会话-从 **`grainlet/auth`**． 你的应用拥有API、cookies和OAuth SDK：

```js
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: 'user@example.com',
              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](https://npmcn.com/package/grainlet/auth/README.md)**。

对于电池（包括谷歌，Apple和GitHub登录），请配置 `createAuthSdk()` 并添加 `GoogleSignIn(sdk)`， `AppleSignIn(sdk)`, `GitHubSignIn(sdk)` 到providers数组。SDK处理提供程序脚本， 弹出窗口，令牌，并调用您的后端。完整的指南: **[auth-sdk / README.md](https://npmcn.com/package/grainlet/auth-sdk/README.md)**。

## i18n

命名空间JSON转换-从 **`grainlet/i18n`**：

```js
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](https://npmcn.com/package/grainlet/i18n/README.md)**。

## 查询

异步服务器状态-从 **`grainlet/query`**． 查询和突变字段 是Grainlet访问器(`data()`， `isPending()`)，而不是普通属性。

```js
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](https://npmcn.com/package/grainlet/query/README.md)**。

## 路由

历史API路由器-不需要哈希

```js
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"`：

```js
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](https://npmcn.com/package/grainlet/route/README.md)**。

## 苏维埃社会主义共和国

```js
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](https://npmcn.com/package/grainlet/ssr/README.md)**。

## 测试

`grainlet/testing` 使用来自Vitest、Jest、JSDOM、happy-dom或 浏览器。它不捆绑DOM实现。

```js
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](https://npmcn.com/package/grainlet/testing/README.md)**。

## Devtools

在创建应用程序信号之前安装钩子，然后呈现面板 仅在开发阶段：

```js
import { GrainletDevtools, installDevtoolsHook } from 'grainlet/devtools';

installDevtoolsHook();

<GrainletDevtools queryClient={queryClient} />
```

检查器显示信号/效果/所有者事件和查询缓存状态。全 导游: **[devtools / README.md](https://npmcn.com/package/grainlet/devtools/README.md)**。

## 常见问题

### 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 和提交记录判断。

