具有样式组件和宗地的简单流式SSR React
通过我的Adobe Stock Photo许可进行照片。
当我第一次使用Next.js时,我最喜欢的一件事是它使Webpack所需的大量样板几乎消失了。 它还规定了简单,合乎逻辑的约定,如果您遵循这些约定,则可以轻松获得成功。
与以前创建服务器端渲染(SSR)React应用程序的复杂性相比,我发现它在简化性方面有了巨大的进步。
但是,去年年初,我意识到可以在与核心React API保持紧密联系的同时为我解决相同问题的新工具。
我对Next.js的最大了解之一是它的自定义路由-尽管使用简单-替代的React Router确实很棒,并且附带了很棒的动画库,我喜欢创建漂亮的,易于使用的东西!
因此,在2018年初,我放弃了Next.js和Webpack,以寻求“更接近金属”的东西,并开始使用Parcel构建React应用。
在本文中,我想向您展示如何使用Parcel构建应用程序以创建具有样式组件的流服务器端渲染的React应用程序。
如果您想知道让我感到兴奋还是尚未尝试过Parcel的东西-Parcel是Javascript Land中较新的模块捆绑包。
您认为:“这是我必须学习的另一种工具”。
没事 包裹不会那样滚动。 这是零配置。
它只是工作。
您可以导入.css文件,图像以及任何其他所需的文件,它的工作原理与您期望的完全相同。
这使得制作使用React生态系统中所有最新和最出色功能的通用应用程序变得非常容易,包括代码拆分,流式渲染,甚至是差分捆绑,这使得轻松获得最新的性能优化变得非常容易!
我想使用新的React lazy和Suspense API来实现代码拆分,但是,服务器端仍不支持它,因此我们将使用类似的替代方法。
在某些情况下仍可能略高于 Next.js更冗长,但为我用的情况下,我更喜欢额外的定制。 我想如果您对工具进行了评估已经有一段时间了,就会发现事情变得如此简单,您会感到惊讶。
旨在使您能够继续学习并获得一个不错的新样板。
我始终有一个个人目标,就是要使东西尽可能轻便。 如果这不是SSR,我建议您完全检出Hyperapp而不是React。 我为Shopify插件构建了一个非常酷的JS SDK,该插件在整个夏天都为使用它的机器学习提供了建议。
那么,我们还等什么呢? 让我们开始吧!
1.设定
首先,使用以下目录结构创建一个新项目-一个文件,两个文件夹。
- app/
- server/
.gitignore
我们将使用mkdir
创建一个名为stream-all-the-things
的目录。 然后,我们将进入该目录,并创建一个名为app
的文件夹和一个名为server
的文件夹。 最后,我们将使用touch
创建我们的.gitignore
文件。
这是一个快速的小片段。 随意键入每一行或复制并
将整个内容粘贴到您的终端中。
mkdir stream-all-the-things && cd stream-all-the-things
mkdir app
mkdir server
touch .gitignore
这是我们的.gitignore的内容
node_modules
*.log
.cache
dist
接下来,让我们安装所需的依赖项。
npm init
npm i --save react react-dom react-router styled-components react-helmet- async @ 0.2 .0 react-imported-component
npm i --save-dev parcel-bundler react-hot-loader
好了,有一点要在那儿解压缩。 尽管您之前从未见过很多。
您之前可能已经使用过基本的依赖项... react
, react-dom
和react-router
。 然后,我们还使用样式化组件来利用其流式渲染支持 。 除了样式组件是支持流渲染的CSS-in-JS库之外,我已经更喜欢styled-components
! 它自以为是的方法有助于实施最佳实践以及对CSS开发人员友好。
react-helmet-async
是与流SSR一起使用的流行库react-helmet
的异步版本。 它允许您在导航时更改HTML文档开头的信息。 例如,更新页面title
。
另外,我们有parcel-bundler
,可以捆绑, cross-env
来解决Windows中的问题, nodemon
,用于开发服务器, react-hot-loader
用于开发客户端, rimraf
用于清理。
2.包裹开发模式
似乎我们的目标是如何发展,让我们从发展模式开始。
在package.json
的脚本部分添加一个dev
脚本。
"scripts" : {
"dev" : "parcel app/index.html"
}
使用Parcel,您可以简单地为其指定应用程序的入口点,作为开始开发的唯一参数。
现在,让我们创建我们引用的app/index.html
文件。
<!DOCTYPE html>
< html >
<head>
<meta charset="UTF-8">
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body>
<div id="app"></div>
<script id="js-entrypoint" src="./client.js"></script>
</body>
</html>
在其中,另一个对我们尚未创建的文件的引用: client.js
。
这是我们客户应用程序的入口点。 换句话说,就是起点。 这是将渲染初始树的地方。
让我们创建app/client.js
,然后将其分解。
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import { HelmetProvider } from 'react-helmet-async' ;
const element = document .getElementById( 'app' )
const app = (
< HelmetProvider >
<App />
</ HelmetProvider >
)
ReactDOM.render(app, element)
// Enable Hot Module Reloading
if ( module .hot) {
module .hot.accept();
}
最后,在我们可以测试任何东西之前,我们还需要app/App.jsx
。
import React from 'react'
import Helmet from 'react-helmet-async'
const App = () => (
< React.Fragment >
<Helmet>
<title>Home Page</title>
</Helmet>
<div>
Follow me at <a href="https://medium.com/@patrickleet">@patrickleet</a>
</div>
</ React.Fragment >
)
export default App
现在,您应该可以运行npm run dev
来启动您的开发服务器,并重新加载热代码!
➜ npm run dev
> stream-all-the-things@ 1.0 .0 dev Users/me/dev/patrickleet/stream-all-the-things
> parcel app/index.html
Server running at http: //localhost:1234
✨ Built in 192 ms.
让我们来看看!
![](https://i-blog.csdnimg.cn/blog_migrate/b425ba2123d306eafca3f7736b04bb0e.png)
因为您不是我,所以尝试将页面更新为您自己的链接,并请注意您无需重新加载即可查看更改!
3.添加一些样式
我混合使用了全局样式和样式化组件。
让我们添加一些基本的重置和样式,以及定义几个有用的CSS变量,这些变量将在数学上帮助我们进行即将来临的设计冒险。
创建一个文件styles.js
:
import { createGlobalStyle } from 'styled-components'
export const GlobalStyles = createGlobalStyle `
/* Base 10 typography scale courtesty of @wesbos 1.6rem === 16px */
html {
font-size: 10px;
}
body {
font-size: 1.6rem;
}
/* Relative Type Scale */
/* https://blog.envylabs.com/responsive-typographic-scales-in-css-b9f60431d1c4 */
:root {
--step-up-5: 2em;
--step-up-4: 1.7511em;
--step-up-3: 1.5157em;
--step-up-2: 1.3195em;
--step-up-1: 1.1487em;
/* baseline: 1em */
--step-down-1: 0.8706em;
--step-down-2: 0.7579em;
--step-down-3: 0.6599em;
--step-down-4: 0.5745em;
--step-down-5: 0.5em;
/* Colors */
--header: rgb(0,0,0);
}
/* https://css-tricks.com/snippets/css/system-font-stack/ */
/* Define the "system" font family */
/* Fastest loading font - the one native to their device */
@font-face {
font-family: system;
font-style: normal;
font-weight: 300;
src: local(".SFNSText-Light"), local(".HelveticaNeueDeskInterface-Light"), local(".LucidaGrandeUI"), local("Ubuntu Light"), local("Segoe UI Light"), local("Roboto-Light"), local("DroidSans"), local("Tahoma");
}
/* Modern CSS Reset */
/* https://alligator.io/css/minimal-css-reset/ */
body, h1, h2, h3, h4, h5, h6, p, ol, ul, input[type=text], input[type=email], button {
margin: 0;
padding: 0;
font-weight: normal;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul, input[type=text], input[type=email], button {
font-family: "system"
}
*, *:before, *:after {
box-sizing: inherit;
}
ol, ul {
list-style: none;
}
img {
max-width: 100%;
height: auto;
}
/* Links */
a {
text-decoration: underline;
color: inherit;
&.active {
text-decoration: none;
}
}
`
在app/App.jsx
导入GlobalStyles
:
import { Global Styles } from './styles'
然后更改App
以呈现GlobalStyles
组件。
const App = () => (
< div >
<GlobalStyles />
Follow me at <a href="https://medium.com/@patrickleet">@patrickleet</a>
</ div >
)
您的应用看起来不那么难看。
4.路由
接下来我们需要使页面变得简单。
让我们添加React Router。
在您的客户端中,我们需要从React Router导入BrowserRouter
,然后将其包装在一起。
在app/client.js
import { BrowserRouter } from 'react-router-dom'
// ...
const app = (
< HelmetProvider >
<BrowserRouter>
<GlobalStyles />
<App />
</BrowserRouter>
</ HelmetProvider >
)
现在在app/App.jsx
我们需要将当前内容提取到一个新组件中,然后通过路由器加载。 让我们从创建一个新页面开始,使用与App.jsx
当前几乎相同的内容。
创建app/pages/Home.jsx
:
import React from 'react'
import Helmet from 'react-helmet-async'
const Home = () => (
< React.Fragment >
<Helmet>
<title>Home Page</title>
</Helmet>
<div>
Follow me at <a href="https://medium.com/@patrickleet">@patrickleet</a>
</div>
</ React.Fragment >
)
export default Home
然后,修改App.jsx
以具有以下内容:
import React from 'react'
import { Switch, Route, Redirect } from 'react-router-dom'
import Home from './pages/Home'
const App = () => (
< React.Fragment >
<GlobalStyles />
<Switch>
<Route exact path="/" component={Home} />
<Redirect to="/" />
</Switch>
</React.Fragment>
)
export default App
现在,当我们运行我们的应用程序时,它的外观应与之前相同,只是这次它是根据路由/的匹配通过我们的路由器渲染的。
![](https://i-blog.csdnimg.cn/blog_migrate/31ed9cc0cabea0dbf5dadfb5d39d35ba.png)
在继续之前,让我们添加第二条路线,但这一次是“代码拆分”。
让我们创建第二个页面, app/pages/About.jsx
:
import React from 'react'
import Helmet from 'react-helmet-async'
const About = () => (
< React.Fragment >
<Helmet>
<title>About Page</title>
</Helmet>
<div>
This is the about page
</div>
</ React.Fragment >
)
export default About
在app/pages/Loading.jsx
有一个加载组件:
import React from 'react'
const Loading = () => (
< div >
Loading...
</ div >
)
export default Loading
最后是app/pages/Error.jsx
的错误组件:
import React from 'react'
const Error = () => (
< div >
Error!
</ div >
)
export default Error
为了导入它,不幸的是,我想使用新的React.lazy和Suspense API,尽管它们将在客户端上运行,但是一旦进入服务器端渲染,我们会发现ReactDomServer尚不支持Suspense。
相反,我们将依赖于另一个名为react-imported-component的库,它将与客户端和服务器端渲染的应用程序一起使用。
这是我们更新的app/App.jsx
:
import React from 'react'
import { Switch, Route, Redirect } from 'react-router-dom' ;
import importComponent from 'react-imported-component' ;
import Home from './pages/Home.jsx'
import LoadingComponent from './pages/Loading'
import ErrorComponent from './pages/Error'
const About = importComponent( () => import ( "./pages/About" ), {
LoadingComponent,
ErrorComponent
});
const App = () => (
< React.Fragment >
<GlobalStyles />
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/about" render={() => <About />} />
<Redirect to="/" />
</Switch>
</React.Fragment>
)
export default App
现在,我们应该能够导航到/ about来查看我们的新页面。 如果您快速浏览,您将看到页面内容之前出现正在加载...。
![](https://i-blog.csdnimg.cn/blog_migrate/1328bce46a597a861f23fea391dd9a5e.png)
5.布局和导航
现在,我们需要在地址栏中输入路线进行导航,这并不理想。 在转到“服务器端渲染”之前,让我们为页面添加一个通用布局,并添加一个带有导航功能的标题。
让我们从Header开始,以便我们获得clickin'。
创建app/components/Header.jsx
:
import React from 'react' ;
import styled from 'styled-components'
import { NavLink } from 'react-router-dom' ;
const Header = styled.header `
z-index: 100;
position: fixed;
top: 0;
left: 0;
right: 0;
max-width: 90vw;
margin: 0 auto;
padding: 1em 0;
display: flex;
justify-content: space-between;
align-items: center;
`
const Brand = styled.h1 `
font-size: var(--step-up-1);
`
const Menu = styled.ul `
display: flex;
justify-content: flex-end;
align-items: center;
width: 50vw;
`
const MenuLink = styled.li `
margin-left: 2em;
text-decoration: none;
`
export default () => (
< Header >
<Brand>Stream all the things!</Brand>
<Menu>
<MenuLink>
<NavLink
to="/"
exact activeClassName="active"
>Home</NavLink>
</MenuLink>
<MenuLink>
<NavLink
to="/about"
exact activeClassName="active"
>About</NavLink>
</MenuLink>
</Menu>
</ Header >
)
我们需要将其导入并将其放入我们的应用中。
这是更新的App.jsx
:
import React from 'react'
import { Switch, Route, Redirect } from 'react-router-dom' ;
import importComponent from 'react-imported-component' ;
import { GlobalStyles } from './styles'
import Header from './components/Header'
import Home from './pages/Home'
import LoadingComponent from './pages/Loading'
import ErrorComponent from './pages/Error'
const About = importComponent( () => import ( "./pages/About" ), {
LoadingComponent,
ErrorComponent
});
const App = () => (
< React.Fragment >
<GlobalStyles />
<Header />
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/about" render={() => <About />} />
<Redirect to="/" />
</Switch>
</React.Fragment>
我们还创建一个Page组件,每个页面都可以使用该组件来实现一致的Page样式。
创建app/components/Page.jsx
:
然后,在我们的四个页面中,导入新的Page组件,并用它替换每个页面中的包装React.Fragment。
这是Home
:
import React from 'react'
import Helmet from 'react-helmet-async'
import Page from '../components/Page.jsx'
const Home = () => (
< Page >
<Helmet>
<title>Home Page</title>
</Helmet>
<div>
Follow me at <a href="https://medium.com/@patrickleet">@patrickleet</a>
</div>
</ Page >
)
export default Home
并对“ About
页面以及“错误”和“加载”页面执行相同的操作。
我们的应用程序开始看起来更好了!
![](https://i-blog.csdnimg.cn/blog_migrate/a2e8bca53e0e410efb7e5eb2de6f56c5.png)
显然,可以通过无数种方式来设置此应用程序的样式,因此,我将使练习变得更漂亮。
6.流式服务器端渲染
我们达到目标的下一步是添加流服务器端渲染。 如果您一直在关注,您会发现到目前为止,我们已经创建了一个静态客户端应用程序。
从客户端到同构需要在服务器上创建一个新的入口点,然后将加载与我们的客户端入口点加载的相同的App
组件。
我们还将需要其他几个新的npm软件包:
npm i --save llog pino express through cheerio
npm i --save-dev concurrently rimraf nodemon @babel/polyfill cross-env
让我们创建server / index.js:
import path from 'path'
import express from 'express'
import log from 'llog'
import ssr from './lib/ssr'
const app = express()
// Expose the public directory as /dist and point to the browser version
app.use( '/dist/client' , express.static(path.resolve(process.cwd(), 'dist' , 'client' )));
// Anything unresolved is serving the application and let
// react-router do the routing!
app.get( '/*' , ssr)
// Check for PORT environment variable, otherwise fallback on Parcel default port
const port = process.env.PORT || 1234 ;
app.listen(port, () => {
log.info( `Listening on port ${port} ...` );
});
好的,这里有几件事要解压:
- 我们正在使用快递-它很可能是任何其他服务器。 我们实际上并没有做太多事情,因此转换为您选择的服务器应该不难。
- 我们正在为/ dist / clients目录设置一个静态文件服务器。 我们目前没有建立生产资产,但是当我们建立资产时,我们可以将它们放在那里。
- 其他所有路线都将进入ssr。 不用理会服务器上的路由,我们只需执行React Router所做的任何事情即可。
让我们创建ssr
函数。 这可能比本教程的其余部分要复杂得多,但这只是需要做一次,然后基本上不做任何事情。
在继续之前,让我们看一下需要创建的脚本。
"scripts" : {
"dev" : "npm run generate-imported-components && parcel app/index.html" ,
"dev:server" : "nodemon -e js,jsx,html --ignore dist --ignore app/imported.js --exec 'npm run build && npm run start'" ,
"start" : "node dist/server"
"build" : "rimraf dist && npm run generate-imported-components && npm run create-bundles" ,
"create-bundles" : "concurrently \"npm run create-bundle:client\" \"npm run create-bundle:server\"" ,
"create-bundle:client" : "cross-env BABEL_ENV=client parcel build app/index.html -d dist/client --public-url /dist/client" ,
"create-bundle:server" : "cross-env BABEL_ENV=server parcel build server/index.js -d dist/server --public-url /dist --target=node" ,
"generate-imported-components" : "imported-components app app/imported.js" ,
"start" : "node dist/server"
}
现在还有很多。 我突出显示了名称,以使其更易于阅读。 在较高的级别上,我们添加了构建脚本来生成一个包含有关导入组件信息的文件,以及一个可以使用宗地同时构建客户端和服务器捆绑包的构建脚本。
现在,对于导入的组件,我们还需要一个.babelrc
文件。 也许在接下来的几个月中,这种情况将会改变。
{
"env" : {
"server" : {
"plugins" : [ "react-imported-component/babel" , "babel-plugin-dynamic-import-node" ]
},
"client" : {
"plugins" : [
[ "react-imported-component/babel" ]
]
}
}
}
有了这一点,我们要解决两个主要问题。
创建SSR中间件为SSR重新使用客户端HTML数据并从中解析生成的src名称
创建server/lib/ssr.js
:
import React from 'react'
import { renderToNodeStream } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { StaticRouter } from 'react-router-dom'
import { ServerStyleSheet } from 'styled-components'
import { printDrainHydrateMarks } from 'react-imported-component' ;
import log from 'llog'
import through from 'through'
import App from '../../app/App'
import { getHTMLFragments } from './client'
// import { getDataFromTree } from 'react-apollo';
export default (req, res) => {
const context = {};
const helmetContext = {};
const app = (
< HelmetProvider context = {helmetContext} >
<StaticRouter
location={req.originalUrl}
context={context}
>
<App />
</StaticRouter>
</ HelmetProvider >
);
try {
// If you were using Apollo, you could fetch data with this
// await getDataFromTree(app);
const sheet = new ServerStyleSheet()
const stream = sheet.interleaveWithNodeStream(
renderToNodeStream(sheet.collectStyles(app))
)
if (context.url) {
res.redirect( 301 , context.url);
} else {
const [
startingHTMLFragment,
endingHTMLFragment
] = getHTMLFragments({ drainHydrateMarks : printDrainHydrateMarks() })
res.status( 200 )
res.write(startingHTMLFragment)
stream
.pipe(
through(
function write ( data ) {
this .queue(data)
},
function end ( ) {
this .queue(endingHTMLFragment)
this .queue( null )
}
)
)
.pipe(res)
}
} catch (e) {
log.error(e)
res.status( 500 )
res.end()
}
};
使用server/lib/client.js
我们需要读取app/index.html
文件,并将其分为两个块,使上面的流式传输更加容易。
import fs from 'fs' ;
import path from 'path' ;
import cheerio from 'cheerio' ;
export const htmlPath = path.join(process.cwd(), 'dist' , 'client' , 'index.html' );
export const rawHTML = fs.readFileSync(htmlPath).toString();
export const parseRawHTMLForData = ( template, selector = "#js-entrypoint" ) => {
const $template = cheerio.load(template);
let src = $template(selector).attr( 'src' )
return {
src
}
}
const clientData = parseRawHTMLForData(rawHTML)
const appString = '<div id="app"\>'
const splitter = '###SPLIT###'
const [
startingRawHTMLFragment,
endingRawHTMLFragment
] = rawHTML
.replace(appString, ` ${appString} ${splitter} ` )
.split(splitter)
export const getHTMLFragments = ( { drainHydrateMarks } ) => {
const startingHTMLFragment = ` ${startingRawHTMLFragment} ${drainHydrateMarks} `
return [startingHTMLFragment, endingRawHTMLFragment]
}
这将通过服务器呈现我们的应用程序,但是如果不对客户端进行一些小改动,它将无法成功重新连接到客户端应用程序。
我们正在通过SSR功能提供“补水标记”,但尚未使用它们。
在app/client.js
进行以下修改:
1.导入rehydrateMarks
和 importedComponents
import { rehydrateMarks } from 'react-imported-component' ;
import importedComponents from './imported' ; // eslint-disable-line
2.将ReactDOM.render(app, element)
替换为:
// In production, we want to hydrate instead of render
// because of the server-rendering
if (process.env.NODE_ENV === 'production' ) {
// rehydrate the bundle marks
rehydrateMarks().then( () => {
ReactDOM.hydrate(app, element);
});
} else {
ReactDOM.render(app, element);
}
并做了!
现在,当您运行npm run dev:server
或npm run build && npm run start
您将使用服务器端渲染!
结论
我承认,比Next.js还要多的样板,但希望它并没有那么压倒性,并且那里的内容是透明且可理解的。 公平地说,Next.js仍在为我们做更多的事情,例如预取组件。
但是,我仍然更喜欢这种方法,因为正在发生的事情没有什么神秘之处,Webpack配置已完全消失,并且很容易利用动画库来作为我将在练习中留下的React Router。
希望您发现这很有用!
如果您这样做,最好的帮助我的方法是给我一些鼓掌和/或分享!
最好,
帕特里克·李·斯科特
PPS本文是系列文章的一部分。 看看下面的其他部分!
- 第1部分:移至Next.js和Webpack
- 第2部分:使用Docker开发Node.js的更好方法
- 第3部分:增强Node.js的代码质量
- 第4部分:100%代码覆盖率神话
- 第5部分:两层(Docker多阶段构建)层的故事
- 第6部分:引入机器人,让他们维护我们的代码
From: https://hackernoon.com/move-over-next-js-and-webpack-ba367f07545