使用 React 制作全站单页主题的实践

算起来已经有很长一段时间没有折腾过主题了。之前一直都没有什么大变化的原因是,我一直在想一种能否把整个站都做成真正意义上的单页 App(就是不依靠 PJAX 实现)。也想过很多实现的方法,只是一直没有把这些方法真正写成实物验证一下可行性。前一段时间在把 Canvas 的主题用 webpack 重构之后,突发奇想到了一种可行度很高的策略。你现在看到的这个主题,大概就是这个脑洞的产物了。

下文开始瞎扯,如果有什么讲得不对的地方欢迎提出来。

单页化主题有什么不同呢

有关于是将站点单页化还是多页化,以及单页化是否可以给用户更好的体验等问题,一直以来都有很多的观点。但是从现今对各大网站的分析看,毫无疑问单页化的趋势更占上风,个人认为单页化的用户体验也更好一些。

用 PJAX 等技术实现单页化的主题确实已经有很多人做过了,但是对于做出那种基于前端组件化和路由分发的 SPA,并没有见到多少相关的尝试(可能是我见识短浅 emmm)。

相比起使用 PJAX 实现的单页化,SPA 实现有下面这些特点:

  • 更灵活。SPA 对后端的要求更小(当然前提是你不做 SSR),服务端只需要给出渲染一个页面所需要的数据,其他的事情都可以交给前端来做。
  • 更科学。使用 SPA 实现单页化,就可以运用上一些诸如单项数据流的哲学
  • 更清晰。SPA 的项目结构可以很清楚,诸如组件化一类的实现将会很便捷。
  • 更 reactive. 借助一些诸如 React 一类的框架,使用 SPA 实现单页化可以在前端完成一些复杂的视图变化。
  • 更装逼。相比于使用 PJAX 实现单页,SPA 实现更考验一个前端的实现能力和架构能力。
  • …..

还有一些我暂时想不到的特点。然而事物都有两面性,如果选择将整个博客 SPA 化,那么就会有以下麻烦的地方:

  • 实现繁琐,不说搭建开发环境的麻烦,开发的时候也有一些很重复而繁琐的事情要做。相比制作 PJAX 主题来说,码量会更多一些。
  • 和后端对接数据的时候稍显复杂。
  • 打包出的 JS 文件会很大,如果不做组件异步加载或者适当的 code spilt,那么由于前端一个页面就要加载所有需要的组件,bundle 的体积自然比用 PJAX 做大好多好多倍。
  • …..

以及你可能会踩很多的坑。不过既然我们说了是个实践,那么我们还是要尝试一下这个想法的。

技术栈的选择

在做一个 SPA 意义上的主题之前,首先应该是对技术栈的选择,而技术栈的选择直接关系到开发的成本、舒适程度和最后产物的质量。

前端框架

关于框架的选择,很多 SPA 的最佳实践无非是在比较流行的 Angular, React 和 Vue 当中做出选择。

关于三个框架如何选择,这里只想说你认为好就行了。网上有很多关于对比这三个框架的文章,可以参考一下然后做出权衡。前端框架的选择主要考虑的是功能实现是否方便、对后期架构的影响、开发的成本,以及体积的大小等等,再有就是上手的难度和社区支持一类的也很重要。ng 的功能十分强大,项目的架构很完美;但是体积和性能相较后两者比较就相对比较不出色了,而且上手的难度会高一些;React 在性能和体积方面权衡得很适当,而且有很出色的生态系统;Vue 国内很多用户,选择 Vue 作为主题的前端框架也不错;这三者都能很好地胜任 SPA 的制作。

我最熟悉的是 React,所以我选择的自然是它了。鉴于主题的页面逻辑一般不会很复杂,所以我这里用了体积更小的 preact + preact-compat 来代替官方 react,性价比相对会更高。

路由分发

大部分 SPA 的路由分发都是在前端完成的,通过不同的 URI 渲染不同的组件到页面上。这就要求我们在后面的制作过程中将不同功能的组件(例如 header, footer, 文章部分,菜单等等)各自分开,这就是组件化了。对于 React 用户而言,路由的最佳选择无疑是 react-router 了。还记得之前 react-router 4 发布的时候 API 翻天覆地的变换和一堆的 bug(日常任务:黑 react-router (11)),现在明显已经好了很多了,至少在这一次的使用中没有踩到坑。所以就决定是它了。

状态管理

对于 SPA 来说,页面的状态大概会很复杂,所以我们需要一个好的状态管理方案。这里我用的是单一状态树 + 单向数据流的解决方案。

对于 React 而言,可以选择比较流行的 redux 和 mobx,两者相对来说 mobx 会更方便一些,但是依旧是权衡性能和体积,我们这里还是选择 redux. 幸运的是,redux 有一个简化的解决方案 redux-zero,使用它可以大大减少我们掉 redux 的坑的几率(

打包工具

毫无疑问地选择 webpack,个人认为 webpack 是当前最适合用来做单页 App 也是功能和配置都最强大的一款打包工具。自然配置会有些复杂,不过耐心一点就可以了。

样式预处理器

这个看个人喜好,用一个样式预处理器会减少一些开发的繁琐。SASS/SCSS/LESS/Stylus 什么的,任君选择。

其它

除了上面之外,我还用了 Typescript 来写 React 代码,Typescript 真的很爽。当然如果你不熟悉 TS ,你也可以只用 babel 什么的就好了。

具体思想

  1. 首先由后端将所需要的数据(标题,描述,文章列表,当前页面等等)用 JSON 的格式输出到前端的一个隐藏的标签中(可以使用<script type="text/json" id="json-holder"></script>这样的标签),这样方便我们接下来取用。

  2. 设计一个状态树,包含上述由后端发来的数据的字段,以及一些特殊组件的状态。这个状态树应该满足:修改上述数据之后可以触发整个页面视图的重新渲染。这样我们切换页面或者做一些操作的时候,只要修改这个状态树,那么整个页面就会改变。

  3. 根据通过不同的 URI ,由路由展示不同的组件。例如用户访问 / 的时候,渲染首页的最近文章组件;用户访问 /blog/post/xxxxxx 的时候渲染文章页组件……

  4. 将路由、组件和状态树连接起来,确保状态树改变的同时路由展示的组件会更新。

  5. 载入页面的时候,整个 SPA 加载完毕,这时候读取后端发来的嵌入在当前页面的数据,解析为新状态并替换当前状态树(第一次加载页面的时候,这个状态树应该是空的),这样就会触发视图的重新渲染。

  6. 最后一步,也是这个做法很核心的一步,就是监听页面的切换,用户切换页面的时候,使用 XHR 或者 fetch(推荐一个对 fetch 的封装:这里) 等方式先抓取新页面,然后用 selector 取出嵌在新页面里的 JSON 数据,解析后替换当前状态树。

  7. 最后可以对上面的流程做一些适当的优化。这时候后端传出来的 route 等参数就可以派上用场了。

大概像这样:

<Provider store={store}>
    <BrowserRouter basename={'/'}>
      <div data-reactroot>
        <Banner />
        <Menu />
        <SiteInfo />

        <Route exact path={'/'} component={AppIndex} />   // index
        <Route path={'/page/:page'} component={Pagination} />   // pagination
        <Route path={'/blog/post/:slug'} component={Post} />  // blog post
        <Route path={'/pages/:slug'} component={Page} />  // single pages

        <Footer />
      </div>
    </BrowserRouter>
</Provider>

项目架构

以 Hexo 主题为例,我们的 SPA 主题大概是下面这样一个架构:

theme/
  layouts/      # 主题各个页面布局文件,其实我们要做的无非是借助后端在各个页面展示不同的数据和载入文件
  config/       # webpack 的配置
    webpack.config.js
  dist/         # 打包的 SPA
  src/          # 整个 SPA 的源代码
    actions/    # Redux 的 actions
    components/ # 各种组件
    stores/     # Redux 的 store
    styles/     # 样式
    images/     # 图片什么的
    index.tsx   # 项目入口
  package.json
  tsconfig.json
  tslint.json
  ...

(别问我为什么没有 reducers,我用 redux-zero 我自豪(x )

下面是我在项目中用的一份 webpack 配置,可以参考:

const webpack = require('webpack'),
  path = require('path'),
  ExtractTextPlugin = require('extract-text-webpack-plugin');

const srcPath = path.join(__dirname, '/../src');

module.exports = {
  output: {
    path: path.join(__dirname, '/../source/dist'),
    filename: '[name].js',
    publicPath: '/dist/'
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
    alias: {
      '@': srcPath,
      'react': 'preact-compat',
      'react-dom': 'preact-compat'
    }
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        enforce: 'pre',
        include: srcPath,
        exclude: '/node_modules/',
        loader: 'eslint-loader'
      },  // eslint

      {
        test: /\.(ts|tsx)$/,
        enforce: 'pre',
        include: srcPath,
        exclude: '/node_modules/',
        loader: 'tslint-loader',
      },  // tslint
      {
        test: /\.(ts|tsx)$/,
        include: srcPath,
        exclude: '/node_modules/',
        loader: 'awesome-typescript-loader',
      },  // typescript
      {
        test: /\.(js|jsx)$/,
        include: srcPath,
        exclude: '/node_modules/',
        loader: 'babel-loader'
      },  // js babel
      {
        test: /(\.styl)$/,
        exclude: '/node_modules/',
        use: ExtractTextPlugin.extract({
          fallback: [{
            loader: 'style-loader',
          }],
          use: [{
              loader: 'css-loader',
              options: {
                importLoaders: 2,
                minimize: process.env.NODE_ENV === 'production',
                sourceMap: true
              }
            },
            {
              loader: 'postcss-loader',
              options: {
                sourceMap: true
              }
            },
            'stylus-loader'
          ]
        })
      },  // stylus
      {
        test: /\.(png|jpg|gif|woff|woff2|ttf|eot)$/,
        loader: 'url-loader',
        options: {
          limit: 8192
        }
      },

      {
        test: /\.(mp4|ogg|svg)$/,
        loader: 'file-loader'
      }
  }, // module
    cache: true,
    devtool: 'eval-source-map',
    plugins: [
        new webpack.NoEmitOnErrorsPlugin(),
        new webpack.optimize.CommonsChunkPlugin('common'),
        new webpack.DefinePlugin({
        'process.env.NODE_ENV': '"dev"',
        'process.env.REACT_DISTRO': '"preact"'
        }),
        new webpack.HotModuleReplacementPlugin(),
        new ExtractTextPlugin('himawari.css')
    ]
};

问题

目前发现的问题就是,写在文章中的 JS 标签不会执行,这个是有解决方案的,一个一个取出来重新创建一个 script 标签然后 appendChild 到 body 就行了。

以及……preact + redux 的主题好像和 react + mobx 的 MUSE 冲突了2333 暂时还没有找到方案。 问题在于上一步执行文章内的 JS 的时候多处理了一次,导致组件被 render 了两次……emmm……

(待补充)