Example repository for my talk "The bumpy road to Universal JavaScript".
- A client wants a website from you and sends you the requirements
- The website's title is "Random Fox"
- As a user, I want to see a random image of a fox for every reload
- As a fox, I would like to feel comfortable and live in my natural habitat
- You create a React single-page app, finish the project and move on
- One day later the client calls you and says that the image is taking ages to load on their smartphone and that Google is just indexing the loading state of the app.
- You decide to refactor it to a Universal Web Application
- You split
app/start.js
intoapp/start.client.js
app/start.server.js
- You use hydrate in
app/start.client.js
- You use
renderToString()
inapp/start.server.js
and rewrite it to a function - You rewrite
app/index.html
toapp/index.html.js
which uses template strings - You realize the module system mismatch (CJS in node vs. ES2015 in webpack)
- To overcome this, you use babel-node
- You deliberately ignore the hint on not for production use)
- You add
BABEL_ENV
flag in.babelrc.js
to activate different plugins based on the target - You realize that you also need something for the webpack loaders, file-loader in our case (there is a babel-plugin-webpack-loaders but it doesn't work with babel 7 yet)
- You add babel-plugin-file-loader (and duplicate config from
webpack.config.js
) - Now it works, but the server console is printing a funny error message
- You realize that
componentWillMount()
is called on the server and on the client - You also realize that the server output is the loading state, not the final state. Rendering is synchronous, fetching is asynchronous.
- You realize that server-side rendering needs to be done in two phases currently: An asychronous data fetching phase and a synchronous rendering phase
- React Fiber's suspense API could help, but the API from Dan Abramov's talk is not ready yet.
- You think of two possible solutions:
- Not using component hooks, but static hooks (Next.js style) or functions entirely decoupled from the component. This is hard.
- Using
componentDidMount()
(only called on the client) and live with poor server-side rendering.
- You don't want poor server-side rendering (no SEO and no performance advantage). You're also not satisfied with the babel-node approach.
- You bundle the client and server code with webpack: You could either use multi compiler mode (two configs in an array) or do two separate webpack compilations. There are different pros and cons but you remember that both compilations shouldn't deviate too much since the client and the server need to produce the same render output.
- You choose the multi compiler mode because of faster builds
- You give the entries names so that webpack produces two different chunks
- You set
libraryTarget
to"commonjs2"
for the node build - You use webpack-node-externals to tell webpack that it should not bundle
node_modules
for node. Although that's currently not necessary. - You wonder where to put the
index.html.js
? Feels like it belongs to the app, but it's not part of any bundle. So you need to use CJS here. - You put it into
server
for now and rewrite it to CJS - Import the server bundle. You realize that this is a key point in the application: the server needs to import the server bundle somewhere. That's why you configured it to be
"commonjs2"
. - You use the
default
export sinceapp/start.server.js
is a ESM. - You change
<script src="/static/main.js"></script>
to<script src="/static/client.js"></script>
inserver/index.html.js
- You realize that we need to extract the data loading from the life-cycle hook of the component
- One approach is to create a static async function on the component (like Next.js'
getInitialProps
) - These data loading functions usually require data from the
req
, such as URL or query parameters. Since we can't depend on env specific objects here, we need to introduce an abstraction. We can use node's request object as blueprint though. - You refactor
App
component so that you're usingprops
instead ofstate
- You call
getInitialProps
inapp/start.server.js
and then renderApp
- You realize that
server/index.html.js
needs to be async now - You also realize that we're blocking the whole request just to wait for the app. You could already send out all the static parts like stylesheets or the client bundle.
- You could use HTTP/2 server push for that. Google engineers recommend to "push just enough resources to fill idle network time".
- But you could also:
- Use streams and
- Move the script tag into
<head>
and adddefer
- If only there was the possibility to stream a template string... oh wait, there's stream-template on npm.
- You update
server/start.js
to use.pipe()
- You pause to cry little tears of joy
- But now you're getting an error from node-fetch:
Error: only absolute urls are supported
- You realize that the universal code needs to access our own API. We could move that data loading out of the universal code and treat it as server-only code. But that doesn't scale well when we have multiple pages. Because we wan't our app to also work as a regular single-page app as soon as the hydration finished.
- You fix it by using an absolute URL. Yes, your server is doing an HTTP request against itself.
- That's ok since your API server could be a totally different server
- It could also be fixed with GraphQL and a SchemaLink instead of a HttpLink
- You realize that the client-side code seems to remove the image again
- You take a look a the console in development. React logs
Warning: Expected server HTML to contain a matching <p> in <main>.
- You realize that the client-side
App
is initialized with different props - You refactor
app/start.server
to return the rendered HTML and the preloaded state - You refactor
server/index.html.js
to include the preloaded state in the HTML. - You put the preloaded state after the rendered HTML for performance reasons
- You refactor
app/start.client
to use the preloaded state - You realize that you just opened up the possibility for XSS attacks if
__PRELOADED_STATE__
contains user data - You use serialize-javascript which also serializes
Date
andRegExp
objects properly - Realize that everything is working as expected and live a happy life
- You get an angry call around midnight from the client that you're not properly using HTTP caching
- You're telling the client that it's just a small configuration and then it'll be done in 5 minutes
- You think: since you're already at it, we can also gzip the assets
- You add the connect-gzip-static middleware
- You add the compression-webpack-plugin to the
web
compilation to compress the output - You configure webpack to use chunk hashes for the
web
output filenames - Everything's working fine but then you realize an error in your browser console:
Uncaught SyntaxError: Unexpected token <
- You realize that the
server/index.html.js
is referencing<script defer src="/static/client.js"></script>
but the filename has a chunkhash now. - You're thinking about writing a dynamic require that tries to grep
client.*.js
- You remember that webpack gives you stats with all the filenames
- You install webpack-assets-manifest
- You require the
manifest.json
insideserver/index.html.js
and render the correct URL
- On the next day you get a call from the client that they now have a lawsuit because they don't have an imprint
- The client asks you to add a link to a separate page with the imprint
- You create a separate
app/Imprint.js
with the address - You realize that you need a router, but you're too lazy to pick one so you decide to use the good old regex router.
- Remember that regexs can be unsafe which means that they can block the event loop on certain input. Don't use unsafe regexs for routing—or use a router.
- The router picks the component based on the incoming request
- You realize that you also need a 404 NotFound component now
- You decide to rename
app/App.js
toapp/Home.js
- You refactor
app/start.server.js
to use the router - You also need to call
getInitialProps
if present - Now you realize: since the router requires the request object, you need to pass the request object through all functions
- But you could also refactor the code so that
server/index.html.js
receives a promise of an app instead of creating it—which is what you do - Then you try to compile the app and webpack says:
Module not found: Error: Can't resolve './App.js'
- You realize that
app/start.client.js
is still trying to rehydrate the app using theApp
component - First you think about adding the component to the preloaded state, but then you realize that you can't serialize functions
- So you basically got two options:
- Re-route the request on the client although you already have that information
- Serialize and deserialize the routing result which means that we have to maintain a map of components because we need to find out which component should be rendered based on the serialized route result.
- You decide to use the former one because you don't want to maintain that map
- You test the routing and you're pretty satisfied with it
- Just as you're about to turn off the computer, you realize that there is a full page reload between page transitions
- You realize that you somehow need to take over the navigation on the client-side as soon as the application is bootstrapped
- You decide that you want to intercept all click events that bubble up the DOM tree to check if there were any clicks inside an anchor tag. You also realize that you need to take account for CTRL, ALT, etc. clicks.
- You decide to use the small helper library nanohref from the Choo framework
- Inside the callback from nanohref, you need to:
- Create a request object
history.pushState()
the request url- Map the request to a Component
- Call
getInitialProps
on the component if present
- Then you realize: If
getInitialProps
takes very long (in your case this would be the request for fox images), the user won't get any feedback. - So you decide to do a render first, call
getInitialProps
and then do a render again - You have to admit that
getInitialProps
is not the right term on the client-side, so you rename it tofetchData
(which is what React Apollo uses by the way). - You realize that there is a potential error: If
fetchData
takes long and there has been a new navigation event in the meantime, the rendering might get out of sync. - You save the current request, so that you can discard the rendering if there has been a newer request.
- You also realize that the back button is not working
- You know that you have to listen for the
popstate
event - You don't want to call
fetchData
when the back button has been pressed as it would return a random image again - So you decide to serialize the render props using
replaceState
and re-use in thepopstate
event
- You get an angry call from Sean Larkin in the middle of the night telling you that you should code split your app
- Code-splitting means that only the relevant code for that particular part of your app is loaded. Typical split points in a web app are routes or modals.
- You decide that you want to split the app based on the routes
- You know that webpack will create separate chunks (aka files) if it encounters a part of the app that can be loaded asynchronously on demand
- So instead of importing the files directly in
app/router.js
, you load the modules on demand using the dynamicimport()
syntax. - You know that
import()
returns the namespace object as also returned byimport * from "..."
. That's why you need to useawait
on the result and then return the default property. - Since
import()
is only a stage 3 proposal, you need to tell babel how to handle the new syntax. This is done by installing the corresponding @babel/plugin-syntax-dynamic-import. - You recognize in webpack's output that it's producing multiple files, called
0.js
,1.js
and2.js
. In order to get more readable filenames, you use webpack's magic chunk name comment inside theimport()
expression. - Inside
app/start.server.js
andapp/start.client.js
you now need toawait
the router result - In order to show something to the user while the chunk is loading, you need to add a generic
Loading
component which is rendered when a navigation event happens - This makes the
app/start.client.js
considerably more complex because you also need to add the request check for all async calls again - You also notice that this check is also necessary in the
popstate
event since the user could have done a hard reload and then hit the back button - You realize that colocating data fetching with the displaying component is not ideal because then chunk loading and data fetching needs to be done sequentially
- But putting data fetching into the entry chunk is also not ideal because it makes it bigger
- You decide to live with the current trade-off
- Looking into the network tab, you realize that the chunk loading happens sequentially because the
import()
call for the route chunk is inside the initial chunk. - You refactor
app/router.js
so that it also returns the routerouteName
(which should match thechunkName
). - You add the
routeName
to theapp
object that is returned byapp/start.server.js
- You add an
includeRouteChunk
function toserver/index.html.js
which waits until therouteName
has resolved. Then it adds the script tag for the route chunk. - You open the network tab and see that the chunks are now loading in parallel
- The fox is happy, the customer is happy, and Sean Larkin is happy: Everyone is happy.