前言关于SSR和同构的文章已经很多了,但大部分都解决不了我的实际问题。比如如何让多页面支持SSR,如果你比较Elegantlyimplementingisomorphism等问题,大部分文章只提到基本的解决方案。由此,我萌生了解决这些问题的想法,并付诸实践。以Vue和Koa2为例,我就围绕几个点说说一些心得。本文中的代码放在我的示例项目中。如果需要参考,可以拉到本地执行npmrunbuild然后npmrunstart查看。多页面SSR大部分时候单页面SSR就可以满足需求,但是如果有时候需要多页面,比如根据访问请求来决定呈现移动端还是PC版本的页面,或者拆分一些服务,那么单页面SSR可以不再满足需求。同时希望可以自由切换部分页面的SSR与否,也希望支持超时或错误时降级为客户端渲染。那么,如何实现这一切呢?为了实现这一点,我的做法是使用如下目录结构,然后在遍历入口的时候判断是否有entry-server.js,如果有则进行SSR打包,否则使用客户端渲染,如果要按project划分,这个入口可以作为配置vue-router的项目入口。比如你想让PC端使用ssr,移动端使用客户端渲染,那么根据实际需要在koa路由中使用不同的路由,然后如果项目需要使用vue-router,那么只需要设置根据。//routerrouter.get('/',ctrl.home);router.get('/mobile/*',ctrl.mobile);router.get('/pc/*',ctrl.pc);项目的目录结构示例如下:|--components|--views|--pc|--App.vue|--entry-client.js|--entry-server.js|--mobile|--pages|--page1|--page2|--App.vue|--entry-client.js|--util|--...然后开始遍历入口:constfse=require('fs-extra');//...constentryMap={};constpageRoot=path.resolve(process.cwd(),`./src/views`);consttpl=path.resolve(process.cwd(),`./index.html`);awaitnewPromise((resolve,reject)=>{glob(`${pageRoot}/**/entry-client.js`,(err,files)=>{err&&reject(err);files.forEach(item=>{constfileBase=item.replace(`${pageRoot}/`,'').replace('/entry-client.js','');constname=fileBase.replace(/\//g,'-');constserverEntry=item.replace('client','server');consthasSSR=fse.existsSync(服务器入口);constresult={name,entry:item,template:tplfileBase,hasSSR};如果(hasSSR){结果。服务器入口=服务器入口;}entryMap[名称]=结果;});解决(入口地图);});});这样我们就得到了所有页面的配置信息。对于浏览器端渲染,按照惯例导入entry并添加HtmlWebpackPlugin和VueSSRClientPlugin,但是这样在多页面SSR项目中,VueSSRClientPlugin会使得所有entry生成的js渲染到页面中,当页面出错时,需要多次打包。建议先打包SSR,再打包客户端,避免出现一些意外错误。脚本大致不完整的代码如下://run-webpack.js//这部分只是一个简单的webpack打包脚本constwebpack=require('webpack');functionrunCompiler(compiler){returnnewPromise((res,rej)=>{compiler.run((err,stats)=>{showStats(stats);if(err||(stats&&stats.hasErrors())){rej(red(`构建失败!${err||''}`));}res(stats);});});}asyncfunctionrunWebpack(conf){constcompiler=webpack(conf);awaitrunCompiler(compiler);}//每个SSR入口需要单独打包,否则会打包其他入口的代码,导致报错,数据.文件库);等待运行Webpack(confServer);constconfClient=awaitwebpackVueConfig(isProd,false,{data,entry:{[data.name]:data.entry},plugins:[newVueSSRClientPlugin({filename:`../dist/views/${data.fileBase}/vue-ssr-client-manifest.json`})]});A等待运行Webpack(confClient);}catch(e){console.log(e);}}asyncfunctionbuildSSR(entryMap){try{for(constiinentryMap){constitem=entryMap[i];if(item.hasSSR){awaitbuildSSRItem(item);}}}catch(e){console.log(e);}}//这部分比较容易理解,只封装客户端代码asyncfunctionbuildVue(entryMap){try{constconf=awaitwebpackVueConfig({entryMap});等待运行Webpack(conf);}catch(e){console.log(e);}}asyncfunctionbuild(){constentryMap=awaitentries();等待buildSSR(entryMap);awaitbuildVue(entryMap);}打包完成后,可以得到如下结构。此时我们可以按照路由|--views|--mobileindex.htmlvue-ssr-client-manifest.jsonvue-ssr-server-bundle.json|--pcindex.htmlflexible来渲染页面切换SSR完成以上步骤后,我们就已经有了一个多页面的项目了。这个时候,我们还没有提到如何渲染。用户访问时,如果是传统的Client-siderendering,只需要将上面单个项目目录下的index.html返回给用户即可。如果需要SSR渲染,只需要访问项目目录下打包好的json,渲染完成后通过renderer.renderToString返回给用户即可。所以这时候要实现SSR的灵活切换,只需要在访问服务器的时候根据需求自由切换渲染方式即可。以我的项目为例,我将渲染方式绑定到koa的上下文中。在单个路由中,只需要从上午和下午的渲染方式配置即可,如下:开关}=选项;常量上下文=这个;//如果需要ssr,则输入此条目if(isssr){constbundle=require(path.join(ssrPath,name,'vue-ssr-server-bundle.json'));constclientManifest=require(path.join(ssrPath,name,'vue-ssr-client-manifest.json'));constrenderer=createRenderer(bundle,{template:ssrTpl,clientManifest});等待渲染(渲染器,'ssrdemo',上下文);}else{//做传统的ejs或任何你喜欢的constejsName=`${name}/index`awaitcontext.render(ejsName,{layout:false});}};}//服务器//...constapp=newKoa();bindRender(app);//...constrouter=koaRouter();router.get('/pc/*',async(ctx)=>{constissr=isSSRLogic();//判断是否开启SSR,比如awaitctx.renderView({name:'pc',isssr});});优雅解决同构问题why看参考资料的时候很多文章只停留在基本配置和数据存储和渲染的问题上,但实际上项目中用到的逻辑并不仅限于此,比如在server端的时候,如果需要使用Cookies或者UA作为渲染判断条件,会在呈现给用户之前进行渲染,而不是在用户端渲染。同时大部分的实现方法都是通过类似isBrowser的方法进行判断,然后进行一些操作。这种方式不易扩展,更容易造成冗余,不够优雅。更何况,如果我们希望以后可以将这些能力扩展到其他平台,或者在不同的平台引入不同的实现方式,那么就需要考虑如何设计一个通用的SDK。What既然要实现一个通用的SDK,首先想到的就是抽象代码。需要约束所有平台代码来实现相同的API。为了在这些上达成一致,我们需要使用接口来指定API的实现、返回类型等。因此,typescript成为了最好的选择。How一般来说,这套SDK只需要一个承载所有API的公共对象就可以完成。但是由于服务器端渲染时请求的应用上下文的概念,每个请求应该是独立的。如果每次都创建一个实例Creatinganewcontextenvironmenttoexecutecode可以忽略这个问题,但是太消耗性能了,不推荐,但是如果共享同一个环境,应该实例化,避免相互污染。另外,在不同平台实现这个SDK的时候,你通常不想重复实现一些不需要重复实现的代码,只需要关心具体平台需要什么。总结起来,我们需要两个东西:接口接口,用于约定基类和平台代码范式;一个基类类,用于被不同平台继承,包含平台无关的代码,如配置,通用判断等,也包含一些默认实现,当被继承的平台不关心也不需要时实现,给默认值,避免调用错误;例如,如果你需要浏览器、服务器,甚至小程序都有cookies、href、userAgent等常用方法(虽然平台不一定有这个能力),并且希望在不重复判断环境的情况下执行Toast等代码,那么你可以先写一个接口和基类://common/interface.tstypeTCookie={set:(name:string,value:string,option?:cookieOption)=>void;得到:(名称:字符串)=>字符串;remove:(name:string)=>void;};//省略其他类型定义exportinterfaceIKit{Cookie:TCookie;配置:IConfig;Env:IEnv;}//common/index.tsclassKitimplementsIKit{Cookie;配置:{//...};Env:{//...}}然后浏览器和服务器可以分别实现://browser/index.tsimport_Kitfrom"../common/index.ts";constcookies={set(name,value,options){//...文档。饼干=饼干;},get(name){//...返回值;},删除(名称){cookies。设置(名称,'',{过期:Date.now()-24*60*60*1000});}}classKitextends_Kit{//...Cookie=cookies;}//server/index.tsimport_Kitfrom"../common/index.ts";classKitextends_Kit{//server应用上下文需要被传入,每个应用都得到自己独享的sdk,避免污染constructor(context){super();this.Cookie={set(name,value,option={}){context.cookies。设置(名称,值,Object.assign({},{httpOnly:false,secure:false,//...},选项));},get(name){returncontext.cookies.get(name);},remove(name){context.cookies.set(name,'',{expires:Date.now()-1*24*60*60*1000});}};}}如上,我们将完成平台各自的api实现,当我们引入代码时,我们可以通过在webpack中设置不同的别名来导入//webpack.client.config.js//...resolve:{alias:{'@MyKit':'./my-kit/browser'}},//webpack.server.config.js//...resolve:{alias:{'@MyKit':'./my-kit/server'}},到这里已经完成了大部分工作,但是这时候你会发现,由于每个SSR项目都是单独打包的,所以kit代码会被包裹在里面。如果套件代码非常大,并且拥有多个SSR项目,就会变得非常冗余和繁琐。为了解决这个问题,其实有一种方法是在代码中引用并注入服务端的Kit代码,这样你只需要引用一次就可以结束上面的描述了,代码可以在我的sample中找到project,写的比较粗糙。希望能帮助到和我一样有多个SSR页面,甚至想扩展多端打包需求的朋友。
