preface
There have been a lot of articles about SSR and isomorphism, but most of them can not solve my practical problems, such as how to do multiple pages support SSR, if more elegant isomorphism and other problems, most of the articles only mentioned the basic scheme. So I had the idea to solve these problems and put it into practice. Vue with Koa2 as an example, around a few points to say some experience.
The code in this article is put in my example project, if you need reference can be pulled to the local after the first run build NPM run start view.
Multi-page SSR
Most of the time, single page SSR can meet the needs, but if sometimes need to multiple pages, such as according to the access request to decide to present the mobile or PC version of the page, or some business split, so that single page SSR can no longer meet the needs. At the same time, I want to be able to freely switch SSR or not on some pages, and I also want to support demoting client rendering in case of timeout or errors. So how do you do all this?
To achieve this, MY approach is to pass the following directory structure, and then determine whether there is entry-server.js when traversing the entry, if there is SSR packaging, otherwise go to client rendering, if you want to divide by project, this entry can be configured as the entry point of the project vue-Router. For example, if you want the PC side to use SSR and the mobile side to use client rendering, you can use different routes in KOA routing according to actual needs. Then, if the project needs vue-Router, you just need to set base.
// router
router.get('/', ctrl.home);
router.get('/mobile/*', ctrl.mobile);
router.get('/pc/*', ctrl.pc);
Copy the code
An example of a project directory structure is as follows:
|-- components
|-- views
|-- pc
|-- App.vue
|-- entry-client.js
|-- entry-server.js
|-- mobile
|-- pages
|-- page1
|-- page2
|-- App.vue
|-- entry-client.js
|-- util
|-- ...
Copy the code
Then start traversing the entry:
const fse = require('fs-extra');
// ...
const entryMap = {};
const pageRoot = path.resolve(process.cwd(), `./src/views`);
const tpl = path.resolve(process.cwd(), `./index.html`);
await new Promise((resolve, reject) = > {
glob(`${pageRoot}/**/entry-client.js`, (err, files) => {
err && reject(err);
files.forEach(item= > {
const fileBase = item
.replace(`${pageRoot}/ `.' ')
.replace('/entry-client.js'.' ');
const name = fileBase.replace(/\//g.The '-');
const serverEntry = item.replace('client'.'server');
const hasSSR = fse.existsSync(serverEntry);
const result = {
name,
entry: item,
template: tpl
fileBase,
hasSSR
};
if (hasSSR) {
result.serverEntry = serverEntry;
}
entryMap[name] = result;
});
resolve(entryMap);
});
});
Copy the code
In this way, we get the configuration information of all pages. For browser-side rendering, we can simply import entry and add HtmlWebpackPlugin and VueSSRClientPlugin according to the common practice. However, in the multi-page SSR project, VueSSRClientPlugin renders all entry generated JS to the page, causing the page to fail. At this time, it is necessary to package multiple times, and it is recommended to package SSR and then the client to avoid some unexpected errors. Rough incomplete code for the script is as follows:
// run-webpack.js
// This part is just a simple script for webpack
const webpack = require('webpack');
function runCompiler(compiler) {
return new Promise((res, rej) = > {
compiler.run((err, stats) = > {
showStats(stats);
if (err || (stats && stats.hasErrors())) {
rej(red(`Build failed! ${err || ' '}`));
}
res(stats);
});
});
}
async function runWebpack(conf) {
const compiler = webpack(conf);
await runCompiler(compiler);
}
// Each SSR entry needs to be packaged separately, otherwise other entry codes will be packaged, resulting in errors
async function buildSSRItem(data) {
try {
const confServer = await webpackVueSSRConfig(
data.serverEntry,
data.fileBase
);
await runWebpack(confServer);
const confClient = await webpackVueConfig(isProd, false, {
data,
entry: {
[data.name]: data.entry
},
plugins: [
new VueSSRClientPlugin({
filename: `.. /dist/views/${data.fileBase}/vue-ssr-client-manifest.json`]}}));await runWebpack(confClient);
} catch (e) {
console.log(e); }}async function buildSSR(entryMap) {
try {
for (const i in entryMap) {
const item = entryMap[i];
if (item.hasSSR) {
awaitbuildSSRItem(item); }}}catch (e) {
console.log(e); }}// This part is easy to understand, just package the client code
async function buildVue(entryMap) {
try {
const conf = await webpackVueConfig({
entryMap
});
await runWebpack(conf);
} catch (e) {
console.log(e); }}async function build() {
const entryMap = await entries();
await buildSSR(entryMap);
await buildVue(entryMap);
}
Copy the code
Once packaged, we have the following structure, and now we can render the page according to the route
|-- views
|-- mobile
index.html
vue-ssr-client-manifest.json
vue-ssr-server-bundle.json
|-- pc
index.html
Copy the code
Flexible switching SSR
After completing the above steps, we now have a multi-page project without actually talking about how to render. When the user accesses, if traditional client-side rendering is required, just return the index.html from the single project directory above to the user. If you need SSR rendering, you just need to access the json package in the project directory and render it back to the user via renderer. RenderToString.
At this time, to realize flexible SSR switching, you only need to switch the rendering mode freely according to the demand when accessing the server. Taking my project as an example, I bound the rendering method to the KOA context, and only need to configure the rendering mode from the previous afternoon in a single route, as follows:
function bindRender(app) {
app.context.renderView = async function(options) {
const {
name,
isssr = false / / SSR switch
} = options;
const context = this;
// If you need SSR, enter this entry
if (isssr) {
const bundle = require(path.join(
ssrPath,
name,
'vue-ssr-server-bundle.json'
));
const clientManifest = require(path.join(
ssrPath,
name,
'vue-ssr-client-manifest.json'
));
const renderer = createRenderer(bundle, {
template: ssrTpl,
clientManifest
});
await render(renderer, 'ssrdemo', context);
} else {
// Do traditional EJS or whatever you prefer
const ejsName = `${name}/index`
await context.render(ejsName, {
layout: false}); }}; }// server
// ...
const app = new Koa();
bindRender(app);
// ...
const router = koaRouter();
router.get('/pc/*'.async (ctx) => {
const isssr = isSSRLogic(); // Decide whether to enable SSR judgment, such as whether to log in or not
await ctx.renderView({
name: 'pc',
isssr
});
});
Copy the code
At this point, visit the PC path and you will see the page rendered by the server
A visit to Mobile is a blank page rendered by the client
Gracefully solve isomorphism problems
why
When I looked at the resources, a lot of the articles just stopped at basic configuration and getting data storage and rendering, but there was more logic to the project than that. For example, when rendering on the server side, if you need Cookies or UA as rendering criteria, render before rendering to the user, not on the client side.
At the same time, most implementations are judged by isBrowser-like methods and then perform some operations, which is not easy to expand and cause redundancy, not elegant.
What’s more, if we want to extend these capabilities to other platforms, or introduce different implementations on different platforms, then we need to consider how to design a common SDK.
What
Since we want to implement a generic SDK, the first thing to think about is abstract code. All platform code needs to be constrained to implement the same API. In order to do this, we need interfaces to specify API implementations, return types, etc. Typescript is the best choice.
How
In general, the SDK only need one carrying ordinary objects can do all of the API, but due to the service side rendering request application context concept, namely each request should be independent, if every time you create an instance to create a new context to execute the code, can not consider this problem, but such performance overhead is too big, Not recommended, but if you share an environment, they should all be instantiated to avoid mutual contamination.
In addition, when implementing this SDK on different platforms, you usually don’t want to re-implement code that doesn’t need to be re-implemented, just care about what is needed for that particular platform.
To sum up, we need two things: an interface, a paradigm for the protocol base classes and platform code; A base class, which is used to be inherited by different platform implementations, contains platform-independent code, such as configuration, general judgments, and some default implementations, which are given default values to avoid invocation errors when the inherited platform does not care about or need implementation;
For example, if you want your browser, server, or even applet to have cookies, href, userAgent and other common methods (although the platform may or may not have this capability), and want to execute code like Toast without having to repeatedly determine the environment, you can start by writing an interface and base class:
// common/interface.ts
type TCookie = {
set: (name: string, value: string, option? : cookieOption) = > void;
get: (name: string) = > string;
remove: (name: string) = > void;
};
// Omit other type definitions
export interface IKit {
Cookie: TCookie;
Config: IConfig;
Env: IEnv;
}
// common/index.ts
class Kit implements IKit {
Cookie;
Config: {
// ...
};
Env: {
// ...}}Copy the code
Then the browser side and the server side can implement their own:
// browser/index.ts
import _Kit from ".. /common/index.ts";
const cookies = {
set(name, value, options) {
/ /...
document.cookie = cookie;
},
get(name) {
// ...
return value;
},
remove(name) {
cookies.set(name, ' ', {
expires: Date.now() - 24 * 60 * 60 * 1000}); }}class Kit extends _Kit {
// ...
Cookie = cookies;
}
// server/index.ts
import _Kit from ".. /common/index.ts";
class Kit extends _Kit {
// The server needs to pass in the application context, and each application gets its own SDK to avoid contamination
constructor(context) {
super(a);this.Cookie = {
set(name, value, option = {}) {
context.cookies.set(
name,
value,
Object.assign(
{},
{
httpOnly: false.secure: false.// ...
},
option
)
);
},
get(name) {
return context.cookies.get(name);
},
remove(name) {
context.cookies.set(name, ' ', {
expires: Date.now() - 1 * 24 * 60 * 60 * 1000}); }}; }}Copy the code
As mentioned above, we have completed the API implementation for each platform. At this point, if you want the web request to use AXIos on the client side and Request on the server side, just implement it as required.
Later, when we import the code, we do so by setting different aliases in the Webpack
// webpack.client.config.js
/ /...
resolve: {
alias: {
'@MyKit': './my-kit/browser'}},// webpack.server.config.js
/ /...
resolve: {
alias: {
'@MyKit': './my-kit/server'}},Copy the code
This is where most of the work is done, but at this point you will find that because each SSR program is packaged separately, you will wrap the kit code in it. If the kit code is very large and you have multiple SSR programs, it becomes very redundant. One way to solve this problem is to say, The Kit code is referenced on the server and injected into the code so that it only needs to be referenced once.
conclusion
The code described above can be found in my sample project, which is a little rough. I hope to help with the same as I have many pages OF SSR and even hope to expand multi-terminal packaging needs of friends.