Solid.js 实战:拆解官方 RealWorld 项目

📑 目录


一、引言:细粒度响应式------不渲染不更新的新范式

2021 年,Solid.js 的创始人 Ryan Carniato 发布了一个名为 solidjs/solid-realworld 的官方示例项目。这个项目本身并不大------它只是一个遵循 RealWorld 规范的 CRUD 应用,实现了文章管理、用户认证、评论、标签、分页等标准功能。但它的特别之处在于:它是 Solid.js 设计哲学的一次完整实践 ,浓缩了这个框架区别于 React 和 Vue 的核心思想------细粒度响应式

如果你打开 Solid.js 的官方网站,会看到这样一行描述:

"Solid.js 是一个用于构建用户界面的声明式 JavaScript 库。它不使用虚拟 DOM,而是使用编译器将 JSX 转换为细粒度的 DOM 更新。"

这句话有三个关键词:声明式无虚拟 DOM细粒度更新。它们共同构成了 Solid.js 的技术宣言。

对比 React,React 的宣言是"组件化+虚拟 DOM+单向数据流";对比 Vue,Vue 的宣言是"模板语法+响应式代理+渐进式"。每个框架都有自己的"第一性原理"------Solid.js 的选择是:放弃虚拟 DOM,直接编译到真实 DOM 操作,在信号级别实现精确更新

这就引出了本文的核心问题:如果 React 的虚拟 DOM 是"在内存中对比差异,然后批量更新真实 DOM",那么 Solid.js 的"不渲染不更新"究竟是怎么做到的?这种设计带来了哪些实际收益?

在接下来的内容中,我们将通过拆解 solidjs/solid-realworld 的源码,一步步回答这些问题。你将从项目结构、状态管理、路由设计、组件模型、API 封装五个维度,深入理解 Solid.js 的架构设计。

🤔 思考:虚拟 DOM 在 2013 年是一个革命性的想法,但十年后的今天,它仍然是"性能银弹"吗?当框架的更新粒度从"组件级"细化到"信号级",我们是否还需要虚拟 DOM 这个中间层?


二、项目全景:solidjs/solid-realworld

技术栈一览

solidjs/solid-realworld 的项目技术栈非常简洁:

技术 版本 用途
Solid.js 1.3.3 核心框架
Rollup 2.52 构建工具
babel-preset-solid 1.3.3 JSX 编译
marked --- Markdown 渲染
Yarn --- 包管理

值得注意的是,这个项目使用的是 Rollup 而非 Vite。这是因为当时(2021 年)Solid.js 的 Vite 集成方案尚未成熟,而 Rollup 作为最成熟的库打包工具,配合 babel-preset-solid 实现了 JSX 到 DOM 操作的编译转换。

目录结构

复制代码
src/
├── index.js                 # 入口:render + Provider 包装
├── App.js                   # 根组件:路由匹配 + 懒加载 + Suspense
├── components/              # UI 组件
│   ├── ArticleList.js       # 文章列表
│   ├── Comment.js           # 评论组件
│   ├── NavBar.js            # 导航栏
│   ├── Pagination.js        # 分页
│   └── ...
├── pages/                   # 页面组件
│   ├── Article/             # 文章详情页
│   ├── Auth.js              # 登录/注册页
│   ├── Editor/              # 编辑页
│   ├── Home/                # 首页
│   ├── Profile/             # 用户主页
│   └── Settings.js          # 设置页
└── store/                   # 状态管理 + API 层
    ├── index.js             # Context + Provider + createStore
    ├── createAgent.js       # fetch 封装工厂
    ├── createArticles.js    # 文章 API
    ├── createAuth.js        # 认证 API
    ├── createComments.js    # 评论 API
    ├── createCommon.js      # 公共 API
    ├── createProfile.js     # 用户资料 API
    └── createRouteHandler.js # 自定义路由

这个目录结构清晰地展示了分层思想:components/ 存放共享 UI 组件,pages/ 存放页面级组件,store/ 存放状态管理和 API 逻辑。每一层各司其职,耦合度低。

运行项目

复制代码
git clone https://github.com/solidjs/solid-realworld.git
cd solid-realworld
yarn install
yarn dev

项目启动后,会连接 https://api.realworld.io/api 作为后端 API。这是一个由 RealWorld 官方维护的演示后端,提供了完整的文章、用户、评论等 RESTful 接口。

🤔 思考:早期 Solid.js 项目选择 Rollup 而非 Vite,反映了新兴框架生态的演进轨迹。在做框架选型时,了解"生态成熟度"有时比"技术先进性"更重要。今天的 Solid.js 已经全面拥抱 Vite,但 Rollup 版本的历史代码反而更清晰地展示了框架本身的特性。


三、Solid.js 核心:细粒度响应式

3.1 createSignal------最基础的信号

createSignal 是 Solid.js 中最基础的响应式原语。它返回一个包含 getter 和 setter 的二元组:

复制代码
import { createSignal } from "solid-js";

const [count, setCount] = createSignal(0);

// 读取值(注意:count 是函数,不是变量!)
console.log(count()); // 0

// 更新值
setCount(1);
console.log(count()); // 1

// 函数式更新
setCount(prev => prev + 1);

关键区别 :与 React 的 useState 不同,createSignal 返回的 count函数 而非变量。这意味着每次读取 count() 时,Solid.js 可以追踪到"谁在读取这个信号",从而实现精确的依赖追踪。

3.2 createStore------深层响应式对象

对于复杂对象,createStore 提供了路径级别的响应式更新:

复制代码
import { createStore } from "solid-js/store";

const [state, setState] = createStore({
  user: { name: "Alice", age: 25 },
  articles: []
});

// 路径更新------只更新 name,不影响 age
setState("user", "name", "Bob");

// 批量更新
setState({
  articles: [{ title: "Hello" }]
});

createStore 的核心能力是路径更新 。当你调用 setState("user", "name", "Bob") 时,只有 user.name 这个路径上的依赖会被通知更新,其他部分完全不受影响。

3.3 createResource------异步数据管理

在 RealWorld 项目中,createResource 是处理异步数据的关键工具:

复制代码
import { createResource } from "solid-js";

const [userId, setUserId] = createSignal(1);
const [user] = createResource(userId, fetchUser);

// 当 userId 变化时,createResource 自动重新请求
// user() 返回 { loading: true, data: undefined } 或 { loading: false, data: {...} }

createResource 的第一个参数是"信号源"------当这个信号变化时,资源会自动重新获取。这与 React 的 useEffect + useState 组合不同,不需要手动管理依赖数组。

3.4 createComputed------自动追踪的派生值

复制代码
import { createSignal, createComputed } from "solid-js";

const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Doe");
const fullName = createComputed(() => `${firstName()} ${lastName()}`);

// 当 firstName 或 lastName 变化时,fullName 自动重新计算

细粒度更新 vs 虚拟 DOM 整树更新

下面这张图清晰地展示了 Solid.js 和 React 的更新机制差异:

解读:Solid.js 的更新路径是"信号 → DOM 节点",没有中间环节。React 的更新路径是"状态 → 组件执行 → 虚拟 DOM 创建 → Diff → 补丁 → 真实 DOM",多出了三个中间步骤。

🤔 思考:细粒度响应式是否一定比虚拟 DOM 性能更好?答案是否定的。在简单场景下(如一个按钮点击),两种方案几乎没有差别。但在复杂场景下(如大型数据表格中仅更新一行数据),细粒度响应式的优势会非常明显------它避免了整棵虚拟 DOM 树的 Diff 计算。然而,细粒度响应式的代价是更复杂的内存管理(追踪成千上万个依赖关系)和更高的心智负担。


四、状态管理:Context + createStore

4.1 Provider 设计

RealWorld 项目的状态管理核心在 store/index.js 中:

复制代码
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import createAgent from "./createAgent";

export function Provider(props) {
  const router = createRouteHandler(),
    [state, setState] = createStore({
      articles: [],
      comments: [],
      tags: [],
      profile: null,
      currentUser: null,
      page: 0,
      totalPagesCount: 0,
      token: localStorage.getItem("jwt")
    }),
    actions = {},
    store = [state, actions],
    agent = createAgent(store);
  
  // 创建各领域的 action 模块
  articles = createArticles(agent, actions, state, setState);
  auth = createAuth(agent, actions, setState);
  comments = createComments(agent, actions, state, setState);
  profile = createProfile(agent, actions, state, setState);

  return (
    <RouterContext.Provider value={router}>
      <StoreContext.Provider value={store}>
        {props.children}
      </StoreContext.Provider>
    </RouterContext.Provider>
  );
}

export function useStore() { return useContext(StoreContext); }
export function useRouter() { return useContext(RouterContext); }

这个设计有几个关键点:

  1. 单一 Store + 模块化 Action :整个应用只有一个 createStore,但 action 函数被拆分到多个模块中。这类似于 Vuex 的单一状态树 + 模块化设计。

  2. Context 传递 Store 引用useStore() 返回的 [state, actions] 是同一个引用------state 的更新不会导致 Context 重新传递,而是通过 createStore 的细粒度响应式机制通知到具体的使用者。

  3. Agent 注入 :每个 action 模块都接收 agent 实例,这是 API 调用层,实现了状态管理和网络请求的解耦。

4.2 细粒度更新 vs 全局状态刷新

复制代码
// 在组件中使用
const [store, actions] = useStore();

// 只有 articles 变化时,这个组件才会更新
// 即使 store 中的 token 或 currentUser 变化,也不会影响这里
console.log(store.articles);

关键差异 :在 React 中,useContext 的变化会导致所有消费该 Context 的组件重新渲染。但在 Solid.js 中,useStore 返回的是 createStore 的代理对象------组件只会在它实际读取的字段变化时才会更新。

🤔 思考:React 社区的 Context + useReducer 模式在大型应用中面临着"不必要的重渲染"问题,开发者不得不使用 useMemo、React.memo、useCallback 等手段来优化。而 Solid.js 的 createStore 从设计层面解决了这个问题------组件只订阅它实际读取的字段,精度达到了属性级别。这不是"优化",而是"默认正确"。


五、路由:自定义极简路由

5.1 createRouteHandler 实现

RealWorld 项目没有使用第三方路由库,而是自己实现了一个基于 location.hash 的极简路由:

复制代码
// store/createRouteHandler.js
export default function createRouteHandler() {
  const [location, setLocation] = createSignal(
    window.location.hash.replace("#", "")
  );

  // 监听 hash 变化
  window.addEventListener("hashchange", () => {
    setLocation(window.location.hash.replace("#", ""));
  });

  return {
    match(name, pattern) {
      const match = location().match(pattern);
      return match ? { name, params: match.slice(1) } : null;
    },
    getParams() {
      return {};
    }
  };
}

在 App.js 中使用:

复制代码
import { Switch, Match } from "solid-js";

const { match } = useRouter();

<Switch>
  <Match when={match("editor", /^editor\/?(.*)/)}>
    <Editor {...match("editor", /^editor\/?(.*)/).params} />
  </Match>
  <Match when={match("settings", /^settings/)}>
    <Settings />
  </Match>
  <Match when={match("article", /^article\/(.*)/)}>
    <Article {...match("article", /^article\/(.*)/).params} />
  </Match>
  <Match when={match("profile", /^@([^/]*)\/?(favorites)?/)}>
    <Profile {...match("profile", /^@([^/]*)\/?(favorites)?/).params} />
  </Match>
  <Match when={match("", /^#?$/)}>
    <Home />
  </Match>
</Switch>

5.2 设计亮点

  1. 零依赖:整个路由实现不到 30 行代码,不依赖任何第三方库。

  2. Hash 路由 :基于 location.hash,简单可靠,无需服务端配置。

  3. 正则匹配:使用正则表达式进行路由匹配和参数提取,支持动态路由。

🤔 思考 :在 SPA 应用中,路由是一个通用需求,绝大多数项目都会选择 React Router 或 Vue Router。但 RealWorld 项目选择手写路由,说明了一个道理:框架的核心能力足够强时,可以简化周边工具的需求。Solid.js 的细粒度响应式让路由状态管理变得极其简单,不需要复杂的路由库来管理"路由状态"。


六、组件模型:lazy + Suspense + Switch/Match

6.1 App.js 根组件

复制代码
import { lazy, createSignal, createComputed, Switch, Match, Show, Suspense } from "solid-js";
import { useStore, useRouter } from "./store";
import NavBar from "./components/NavBar";

// 懒加载页面
const Settings = lazy(() => import("./pages/Settings"));
const Auth = lazy(() => import("./pages/Auth"));

export default () => {
  const [store, { pullUser }] = useStore(),
    [appLoaded, setAppLoaded] = createSignal(false),
    { match, getParams } = useRouter();

  // 初始化:检查 token
  if (!store.token) {
    setAppLoaded(true);
  } else {
    pullUser();
    createComputed(() => store.currentUser && setAppLoaded(true));
  }

  return (
    <>
      <NavBar />
      <Show when={appLoaded()}>
        <Suspense fallback={<div class="container">Loading...</div>}>
          <Switch>
            <Match when={match("editor", /^editor\/?(.*)/)}>
              <Editor {...getParams()} />
            </Match>
            <Match when={match("settings", /^settings/)}>
              <Settings />
            </Match>
            {/* 其他路由匹配 */}
          </Switch>
        </Suspense>
      </Show>
    </>
  );
};

6.2 关键特性

  1. 组件函数只执行一次:Solid.js 的组件函数只在初始化时执行一次,不像 React 那样每次状态更新都重新执行。这意味着 Solid.js 中没有 React 的"闭包陷阱"问题。

  2. lazy + Suspenselazy 实现了代码分割,Suspense 配合 createResource 实现异步数据的加载状态管理。这与 React 的 React.lazy + Suspense 非常相似,但底层机制不同------Solid.js 的 Suspense 是细粒度的,可以等待多个异步资源。

  3. Switch/Match :Solid.js 内置的条件渲染组件,等价于 React 中手动实现的 switch-case 模式。

6.3 与 React Suspense 对比

特性 Solid.js Suspense React Suspense
数据源 createResource 第三方库(如 Relay、SWR)
粒度 细粒度,可等待多个资源 组件级
回退 fallback 属性 fallback 属性
错误处理 ErrorBoundary ErrorBoundary

🤔 思考 :Solid.js 的组件模型揭示了一个重要差异:"组件函数只执行一次"意味着 Solid.js 没有"重新渲染"的概念。组件就像是一个"初始化脚本",在首次运行时完成了所有 DOM 节点的创建和信号绑定,之后只有信号的变化会驱动 DOM 更新,组件本身不会重新执行。这就从根本上消除了 React 中 useEffect 依赖数组、useCallback 记忆化等复杂性。


七、API 封装:模块化 agent 设计

7.1 createAgent 工厂函数

复制代码
// store/createAgent.js
export default function createAgent(store) {
  const [state] = store;
  
  async function request(method, url, body) {
    const headers = { "Content-Type": "application/json" };
    if (state.token) {
      headers["Authorization"] = `Token ${state.token}`;
    }
    const response = await fetch(`https://api.realworld.io/api${url}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined
    });
    const result = await response.json();
    if (!response.ok) throw result;
    return result;
  }

  return {
    Auth: {
      current: () => request("GET", "/user"),
      login: (email, password) => request("POST", "/users/login", { user: { email, password } }),
      register: (username, email, password) => request("POST", "/users", { user: { username, email, password } })
    },
    Articles: {
      all: (page) => request("GET", `/articles?offset=${page * 20}&limit=20`),
      bySlug: (slug) => request("GET", `/articles/${slug}`),
    },
    Comments: {
      forArticle: (slug) => request("GET", `/articles/${slug}/comments`),
    },
    Profile: {
      get: (username) => request("GET", `/profiles/${username}`)
    }
  };
}

7.2 createAuth 模块

复制代码
// store/createAuth.js
import { createSignal, createResource, batch } from "solid-js";

export default function createAuth(agent, actions, setState) {
  const [loggedIn, setLoggedIn] = createSignal(false),
    [currentUser, { mutate }] = createResource(loggedIn, agent.Auth.current);

  Object.assign(actions, {
    pullUser: () => setLoggedIn(true),
    async login(email, password) {
      const { user, errors } = await agent.Auth.login(email, password);
      if (errors) throw errors;
      setState({ token: user.token });
      setLoggedIn(true);
    },
    logout() {
      batch(() => {
        setState({ token: undefined });
        mutate(undefined);
      });
    }
  });

  return currentUser;
}

7.3 设计亮点

  1. factory 模式createAgent(store) 接收 store 实例,从中读取 token 用于 JWT 认证。这是一种依赖注入的实现方式。

  2. 模块化组织agent.Authagent.Articles 等按领域划分,每个模块返回 Promise,与 createResource 天然配合。

  3. batch 批量更新logout() 中使用 batch() 包裹多个状态更新,确保它们在一次更新中完成。

🤔 思考 :React 生态中,Next.js 的 API 封装通常通过 fetch 的二次封装 + 环境变量实现。Solid.js 的 agent 模式与之类似,但更简洁------不需要复杂的拦截器机制,因为 Solid.js 的响应式系统天然支持从 store 中读取 token。框架的能力越强,封装工具就越轻量。


八、架构对比:Solid.js vs React vs Vue vs Svelte

8.1 核心维度对比

维度 Solid.js React Vue 3 Svelte
响应式模型 信号(编译时+运行时) 虚拟 DOM Diff Proxy 代理 编译时
更新粒度 细粒度(信号级) 组件级 细粒度(响应式对象) 细粒度(编译后)
虚拟 DOM 有(或虚拟 DOM + 编译器)
组件执行 一次 每次更新 一次(setup) 一次
JSX 支持 原生 原生 需要插件 不支持
学习曲线 中(信号概念新) 中(Hooks 规则)
包体积 ~7KB ~42KB ~33KB ~5KB(编译后)
SSR 支持 需要 Solid Start Next.js Nuxt SvelteKit

8.2 响应式模型对比

React:每次状态变化触发整个组件重新执行,生成新的虚拟 DOM 树,然后与旧树进行 Diff,计算出最小 DOM 操作。

Vue 3:通过 Proxy 拦截对象的读写操作,在 getter 时收集依赖,在 setter 时通知更新。组件级别使用虚拟 DOM 进行渲染优化。

Solid.js:通过编译器将 JSX 中的响应式表达式编译为独立的 DOM 更新操作。每个信号独立追踪依赖,更新时直接操作 DOM。

Svelte:通过编译器将声明式代码转换为高效的命令式 DOM 操作代码。编译时分析变量赋值,生成对应的 DOM 更新代码。

8.3 开发者体验对比

复制代码
// React - 每次 count 变化,整个组件重新执行
function Counter() {
  const [count, setCount] = useState(0);
  console.log("组件渲染"); // 每次 count 变化都会执行
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// Solid.js - 组件只执行一次,只有 count() 所在的 DOM 节点更新
function Counter() {
  const [count, setCount] = createSignal(0);
  console.log("组件初始化"); // 只执行一次
  return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}

<!-- Vue 3 - 模板编译,setup 只执行一次 -->
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
  <button @click="count++">{{ count }}</button>
</template>

🤔 思考:Solid.js 会成为"下一个 React"吗?从技术层面看,Solid.js 在很多方面确实优于 React------更细粒度的更新、更少的样板代码、更好的性能。但框架的成功不仅取决于技术,还取决于生态、社区、企业支持等因素。Solid.js 目前最大的挑战是生态规模------它的第三方库、工具链、学习资源都远不如 React 丰富。但作为一个"更优的响应式方案",Solid.js 正在稳步增长,尤其适合性能敏感型应用。


九、练习 + 部署 + 进阶

9.1 动手练习

练习 1:暗色模式切换

Settings.js 中添加一个"暗色模式"开关,使用 createSignal 管理主题状态,通过 CSS 类切换实现主题切换。重点练习 createSignal 的使用和状态持久化(localStorage)。

练习 2:文章搜索功能

Home.js 中添加搜索框,输入关键词后调用 agent.Articles.all() 的搜索参数,实现文章过滤。重点练习 createSignalcreateResource 的联动。

练习 3:阅读进度条

Article.js 中添加一个固定在页面顶部的阅读进度条,根据文章内容的滚动位置动态更新进度。重点练习 createEffect 和 DOM 事件绑定。

9.2 Vercel 部署

复制代码
# 安装 Vercel CLI
npm i -g vercel

# 在项目根目录创建 vercel.json
echo '{"builds":[{"src":"package.json","use":"@vercel/static-build"}],"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}' > vercel.json

# 部署
vercel --prod

9.3 进阶路径

  1. Solid Start:Solid.js 的元框架,类似 Next.js 之于 React,提供文件路由、SSR、SSG、API 路由等功能。

  2. Solid Native:基于 Solid.js 的跨平台移动开发方案。

  3. 生态工具 :探索 solid-js/router(官方路由)、solid-js/universal(自定义渲染器)、solid-testing-library(测试工具)等。

🤔 思考:从 RealWorld 到生产项目,还需要考虑哪些因素?状态管理方案(是否需要更复杂的方案)、类型安全(TypeScript 集成)、测试策略(单元测试 + E2E)、国际化(i18n 方案)、性能监控等。RealWorld 项目是一个优秀的起点,但它不是生产级模板。


十、结语

回到文章开头的问题:Solid.js 的"不渲染不更新"究竟是怎么做到的?

通过拆解 solidjs/solid-realworld 的源码,我们看到了答案:

  • 状态层面createSignalcreateStore 提供了信号级别的精确追踪,每个状态变化都知道"谁在依赖它"。

  • 组件层面:组件函数只执行一次,没有"重新渲染"的概念,避免了 React 中"父组件渲染 → 子组件渲染"的级联反应。

  • 更新层面:编译器将 JSX 编译为直接的 DOM 操作,没有虚拟 DOM 中间层,信号变化直接映射到 DOM 更新。

Solid.js 的设计哲学可以用一句话概括:"精确知道需要更新什么,然后只更新那一个"。这听起来简单,但实现起来需要编译器、运行时和响应式系统的紧密配合。

前端框架的演进经历了从"直接操作 DOM"(jQuery)到"声明式描述 UI"(React/Vue)再到"精确响应式更新"(Solid.js/Svelte)的过程。Solid.js 不是对 React 的简单改进,而是一种范式转换------它用不同的方式回答了"如何高效更新 UI"这个问题。

对于前端开发者来说,学习 Solid.js 的意义不仅在于掌握一个新框架,更在于理解"响应式"的另一种可能性。它让你意识到:虚拟 DOM 不是 UI 框架的必然选择,而只是一个历史阶段的产物


十一、参考文献

  1. solidjs/solid-realworld 官方仓库

  2. RealWorld 官方规范

  3. Solid.js 官方文档

  4. Solid.js 教程

  5. Solid.js vs React 对比

  6. RealWorld Demo 前端演示

  7. Conduit API 文档

  8. Ryan Carniato - 细粒度响应式宣言

相关推荐
OPEN-F1 小时前
Python进阶教程:正则表达式进阶与文本处理
开发语言·python·正则表达式
zhifou1234561 小时前
java 17升级安装
java·开发语言
for_ever_love__1 小时前
python基础语法学习: 面向对象的三大特性
开发语言·python·学习
不会代码的小猴2 小时前
7. JSON
开发语言·c++·笔记·qt·算法·json
沫璃染墨2 小时前
《Qt从零入门系列(六):信号与槽进阶——从多种连接方式到Lambda表达式》
开发语言·c++·qt·代码规范·设计规范·qt5
YuJie3 小时前
JSBridge 基础知识
前端·javascript
默_笙3 小时前
🍕 后端接口还没写好,前端已经跑起来了?Mock 数据 + axios 接口层,前后端再也不互相"等"
前端·javascript
爱勇宝3 小时前
客户只想看个页面,我却做了一个静态演示发布系统
前端·javascript·后端
ZJU_统一阿萨姆3 小时前
【算子开发】环境搭建与第一个CUDA程序
开发语言·人工智能·系统架构